aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
authorLuke <luke.pulverenti@gmail.com>2016-12-18 00:44:33 -0500
committerGitHub <noreply@github.com>2016-12-18 00:44:33 -0500
commite7cebb91a73354dc3e0d0b6340c9fbd6511f4406 (patch)
tree6f1c368c766c17b7514fe749c0e92e69cd89194a /Emby.Server.Implementations
parent025905a3e4d50b9a2e07fbf4ff0a203af6604ced (diff)
parentaaa027f3229073e9a40756c3157d41af2a442922 (diff)
Merge pull request #2350 from MediaBrowser/beta
Beta
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs561
-rw-r--r--Emby.Server.Implementations/Activity/ActivityManager.cs56
-rw-r--r--Emby.Server.Implementations/Activity/ActivityRepository.cs222
-rw-r--r--Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs21
-rw-r--r--Emby.Server.Implementations/Browser/BrowserLauncher.cs73
-rw-r--r--Emby.Server.Implementations/Channels/ChannelConfigurations.cs29
-rw-r--r--Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs43
-rw-r--r--Emby.Server.Implementations/Channels/ChannelImageProvider.cs55
-rw-r--r--Emby.Server.Implementations/Channels/ChannelManager.cs1645
-rw-r--r--Emby.Server.Implementations/Channels/ChannelPostScanTask.cs257
-rw-r--r--Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs78
-rw-r--r--Emby.Server.Implementations/Collections/CollectionImageProvider.cs84
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs296
-rw-r--r--Emby.Server.Implementations/Collections/CollectionsDynamicFolder.cs34
-rw-r--r--Emby.Server.Implementations/Connect/ConnectData.cs36
-rw-r--r--Emby.Server.Implementations/Connect/ConnectEntryPoint.cs199
-rw-r--r--Emby.Server.Implementations/Connect/ConnectManager.cs1188
-rw-r--r--Emby.Server.Implementations/Connect/Responses.cs85
-rw-r--r--Emby.Server.Implementations/Connect/Validator.cs29
-rw-r--r--Emby.Server.Implementations/Data/BaseSqliteRepository.cs401
-rw-r--r--Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs217
-rw-r--r--Emby.Server.Implementations/Data/ManagedConnection.cs82
-rw-r--r--Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs239
-rw-r--r--Emby.Server.Implementations/Data/SqliteExtensions.cs394
-rw-r--r--Emby.Server.Implementations/Data/SqliteFileOrganizationRepository.cs284
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs5660
-rw-r--r--Emby.Server.Implementations/Data/SqliteUserDataRepository.cs422
-rw-r--r--Emby.Server.Implementations/Data/SqliteUserRepository.cs167
-rw-r--r--Emby.Server.Implementations/Data/TypeMapper.cs54
-rw-r--r--Emby.Server.Implementations/Devices/CameraUploadsDynamicFolder.cs41
-rw-r--r--Emby.Server.Implementations/Devices/DeviceManager.cs299
-rw-r--r--Emby.Server.Implementations/Devices/DeviceRepository.cs208
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs1693
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj435
-rw-r--r--Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs124
-rw-r--r--Emby.Server.Implementations/EntryPoints/KeepServerAwake.cs65
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs343
-rw-r--r--Emby.Server.Implementations/EntryPoints/LoadRegistrations.cs73
-rw-r--r--Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs77
-rw-r--r--Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs41
-rw-r--r--Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs203
-rw-r--r--Emby.Server.Implementations/EntryPoints/StartupWizard.cs59
-rw-r--r--Emby.Server.Implementations/EntryPoints/SystemEvents.cs47
-rw-r--r--Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs85
-rw-r--r--Emby.Server.Implementations/EntryPoints/UsageEntryPoint.cs133
-rw-r--r--Emby.Server.Implementations/EntryPoints/UsageReporter.cs138
-rw-r--r--Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs165
-rw-r--r--Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs24
-rw-r--r--Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs20
-rw-r--r--Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs240
-rw-r--r--Emby.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs839
-rw-r--r--Emby.Server.Implementations/FileOrganization/Extensions.cs33
-rw-r--r--Emby.Server.Implementations/FileOrganization/FileOrganizationNotifier.cs80
-rw-r--r--Emby.Server.Implementations/FileOrganization/FileOrganizationService.cs283
-rw-r--r--Emby.Server.Implementations/FileOrganization/NameUtils.cs81
-rw-r--r--Emby.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs101
-rw-r--r--Emby.Server.Implementations/FileOrganization/TvFolderOrganizer.cs210
-rw-r--r--Emby.Server.Implementations/HttpServer/GetSwaggerResource.cs17
-rw-r--r--Emby.Server.Implementations/HttpServer/HttpListenerHost.cs729
-rw-r--r--Emby.Server.Implementations/HttpServer/HttpResultFactory.cs830
-rw-r--r--Emby.Server.Implementations/HttpServer/IHttpListener.cs46
-rw-r--r--Emby.Server.Implementations/HttpServer/LoggerUtils.cs43
-rw-r--r--Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs224
-rw-r--r--Emby.Server.Implementations/HttpServer/ResponseFilter.cs129
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/AuthService.cs246
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs195
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/SessionContext.cs67
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/Extensions.cs12
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/HttpUtility.cs922
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/RequestMono.cs846
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/SharpWebSocket.cs158
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpListener.cs207
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpRequest.cs620
-rw-r--r--Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpResponse.cs193
-rw-r--r--Emby.Server.Implementations/HttpServer/StreamWriter.cs129
-rw-r--r--Emby.Server.Implementations/HttpServer/SwaggerService.cs47
-rw-r--r--Emby.Server.Implementations/IO/FileRefresher.cs326
-rw-r--r--Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs55
-rw-r--r--Emby.Server.Implementations/IO/ThrottledStream.cs394
-rw-r--r--Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs369
-rw-r--r--Emby.Server.Implementations/Intros/DefaultIntroProvider.cs384
-rw-r--r--Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs148
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs3138
-rw-r--r--Emby.Server.Implementations/Library/LocalTrailerPostScanTask.cs102
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs651
-rw-r--r--Emby.Server.Implementations/Library/MusicManager.cs157
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs45
-rw-r--r--Emby.Server.Implementations/Library/ResolverHelper.cs183
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs74
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs172
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs94
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs314
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs77
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs56
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs62
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs77
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs540
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs56
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs103
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs42
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs85
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs75
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs62
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs250
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs45
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs275
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs288
-rw-r--r--Emby.Server.Implementations/Library/UserManager.cs1029
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs298
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs44
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs84
-rw-r--r--Emby.Server.Implementations/Library/Validators/GameGenresPostScanTask.cs45
-rw-r--r--Emby.Server.Implementations/Library/Validators/GameGenresValidator.cs74
-rw-r--r--Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs42
-rw-r--r--Emby.Server.Implementations/Library/Validators/GenresValidator.cs75
-rw-r--r--Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs45
-rw-r--r--Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs75
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs115
-rw-r--r--Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs45
-rw-r--r--Emby.Server.Implementations/Library/Validators/StudiosValidator.cs74
-rw-r--r--Emby.Server.Implementations/Library/Validators/YearsPostScanTask.cs55
-rw-r--r--Emby.Server.Implementations/LiveTv/ChannelImageProvider.cs85
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs106
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs2414
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs36
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs330
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs16
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs23
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs145
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs115
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs28
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs170
-rw-r--r--Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs1311
-rw-r--r--Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs241
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs110
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs21
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs563
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveTvManager.cs3069
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs219
-rw-r--r--Emby.Server.Implementations/LiveTv/ProgramImageProvider.cs100
-rw-r--r--Emby.Server.Implementations/LiveTv/RecordingImageProvider.cs82
-rw-r--r--Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs83
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs259
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs159
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs577
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs144
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs171
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs276
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs96
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs101
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/core.json179
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/gsw.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/kk.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json178
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json178
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs433
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/au.txt8
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/be.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/br.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ca.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/co.txt8
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/de.txt10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/dk.txt4
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/fr.txt5
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/gb.txt7
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ie.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/jp.txt4
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/kz.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/mx.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/nl.txt6
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/nz.txt10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ru.txt5
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/us.txt22
-rw-r--r--Emby.Server.Implementations/Localization/countries.json1
-rw-r--r--Emby.Server.Implementations/Localization/iso6392.txt487
-rw-r--r--Emby.Server.Implementations/MediaEncoder/EncodingManager.cs236
-rw-r--r--Emby.Server.Implementations/Migrations/IVersionMigration.cs9
-rw-r--r--Emby.Server.Implementations/Migrations/LibraryScanMigration.cs49
-rw-r--r--Emby.Server.Implementations/Migrations/UpdateLevelMigration.cs130
-rw-r--r--Emby.Server.Implementations/News/NewsEntryPoint.cs275
-rw-r--r--Emby.Server.Implementations/News/NewsService.cs77
-rw-r--r--Emby.Server.Implementations/Notifications/CoreNotificationTypes.cs198
-rw-r--r--Emby.Server.Implementations/Notifications/IConfigurableNotificationService.cs8
-rw-r--r--Emby.Server.Implementations/Notifications/InternalNotificationService.cs61
-rw-r--r--Emby.Server.Implementations/Notifications/NotificationConfigurationFactory.cs21
-rw-r--r--Emby.Server.Implementations/Notifications/NotificationManager.cs296
-rw-r--r--Emby.Server.Implementations/Notifications/Notifications.cs547
-rw-r--r--Emby.Server.Implementations/Notifications/SqliteNotificationsRepository.cs337
-rw-r--r--Emby.Server.Implementations/Notifications/WebSocketNotifier.cs54
-rw-r--r--Emby.Server.Implementations/Photos/PhotoAlbumImageProvider.cs34
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs104
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs276
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistsDynamicFolder.cs32
-rw-r--r--Emby.Server.Implementations/Properties/AssemblyInfo.cs30
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs192
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/PeopleValidationTask.cs95
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs140
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/RefreshIntrosTask.cs105
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/RefreshMediaLibraryTask.cs96
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/SystemUpdateTask.cs148
-rw-r--r--Emby.Server.Implementations/Security/AuthenticationRepository.cs318
-rw-r--r--Emby.Server.Implementations/Security/EncryptionManager.cs51
-rw-r--r--Emby.Server.Implementations/Security/MBLicenseFile.cs214
-rw-r--r--Emby.Server.Implementations/Security/PluginSecurityManager.cs355
-rw-r--r--Emby.Server.Implementations/Security/RegRecord.cs12
-rw-r--r--Emby.Server.Implementations/ServerManager/ServerManager.cs353
-rw-r--r--Emby.Server.Implementations/ServerManager/WebSocketConnection.cs295
-rw-r--r--Emby.Server.Implementations/Session/HttpSessionController.cs186
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs1961
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs485
-rw-r--r--Emby.Server.Implementations/Session/WebSocketController.cs288
-rw-r--r--Emby.Server.Implementations/Social/SharingManager.cs100
-rw-r--r--Emby.Server.Implementations/Social/SharingRepository.cs116
-rw-r--r--Emby.Server.Implementations/Sorting/AirTimeComparer.cs71
-rw-r--r--Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs160
-rw-r--r--Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs47
-rw-r--r--Emby.Server.Implementations/Sorting/AlbumComparer.cs46
-rw-r--r--Emby.Server.Implementations/Sorting/AlphanumComparator.cs99
-rw-r--r--Emby.Server.Implementations/Sorting/ArtistComparer.cs51
-rw-r--r--Emby.Server.Implementations/Sorting/BudgetComparer.cs39
-rw-r--r--Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs29
-rw-r--r--Emby.Server.Implementations/Sorting/CriticRatingComparer.cs37
-rw-r--r--Emby.Server.Implementations/Sorting/DateCreatedComparer.cs33
-rw-r--r--Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs69
-rw-r--r--Emby.Server.Implementations/Sorting/DatePlayedComparer.cs69
-rw-r--r--Emby.Server.Implementations/Sorting/GameSystemComparer.cs54
-rw-r--r--Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs58
-rw-r--r--Emby.Server.Implementations/Sorting/IsFolderComparer.cs39
-rw-r--r--Emby.Server.Implementations/Sorting/IsPlayedComparer.cs58
-rw-r--r--Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs58
-rw-r--r--Emby.Server.Implementations/Sorting/MetascoreComparer.cs41
-rw-r--r--Emby.Server.Implementations/Sorting/NameComparer.cs33
-rw-r--r--Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs40
-rw-r--r--Emby.Server.Implementations/Sorting/PlayCountComparer.cs63
-rw-r--r--Emby.Server.Implementations/Sorting/PlayersComparer.cs46
-rw-r--r--Emby.Server.Implementations/Sorting/PremiereDateComparer.cs59
-rw-r--r--Emby.Server.Implementations/Sorting/ProductionYearComparer.cs52
-rw-r--r--Emby.Server.Implementations/Sorting/RandomComparer.cs33
-rw-r--r--Emby.Server.Implementations/Sorting/RevenueComparer.cs39
-rw-r--r--Emby.Server.Implementations/Sorting/RuntimeComparer.cs32
-rw-r--r--Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs37
-rw-r--r--Emby.Server.Implementations/Sorting/SortNameComparer.cs33
-rw-r--r--Emby.Server.Implementations/Sorting/StartDateComparer.cs47
-rw-r--r--Emby.Server.Implementations/Sorting/StudioComparer.cs30
-rw-r--r--Emby.Server.Implementations/StartupOptions.cs33
-rw-r--r--Emby.Server.Implementations/Sync/AppSyncProvider.cs118
-rw-r--r--Emby.Server.Implementations/Sync/CloudSyncProfile.cs302
-rw-r--r--Emby.Server.Implementations/Sync/IHasSyncQuality.cs31
-rw-r--r--Emby.Server.Implementations/Sync/MediaSync.cs500
-rw-r--r--Emby.Server.Implementations/Sync/MultiProviderSync.cs79
-rw-r--r--Emby.Server.Implementations/Sync/ServerSyncScheduledTask.cs95
-rw-r--r--Emby.Server.Implementations/Sync/SyncConfig.cs29
-rw-r--r--Emby.Server.Implementations/Sync/SyncConvertScheduledTask.cs89
-rw-r--r--Emby.Server.Implementations/Sync/SyncHelper.cs24
-rw-r--r--Emby.Server.Implementations/Sync/SyncJobOptions.cs18
-rw-r--r--Emby.Server.Implementations/Sync/SyncJobProcessor.cs988
-rw-r--r--Emby.Server.Implementations/Sync/SyncManager.cs1362
-rw-r--r--Emby.Server.Implementations/Sync/SyncNotificationEntryPoint.cs48
-rw-r--r--Emby.Server.Implementations/Sync/SyncRegistrationInfo.cs31
-rw-r--r--Emby.Server.Implementations/Sync/SyncRepository.cs820
-rw-r--r--Emby.Server.Implementations/Sync/SyncedMediaSourceProvider.cs158
-rw-r--r--Emby.Server.Implementations/Sync/TargetDataProvider.cs188
-rw-r--r--Emby.Server.Implementations/TV/SeriesPostScanTask.cs237
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs249
-rw-r--r--Emby.Server.Implementations/Udp/UdpServer.cs247
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs694
-rw-r--r--Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs176
-rw-r--r--Emby.Server.Implementations/UserViews/DynamicImageProvider.cs172
-rw-r--r--Emby.Server.Implementations/packages.config8
304 files changed, 71309 insertions, 0 deletions
diff --git a/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs b/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs
new file mode 100644
index 000000000..11fd3a872
--- /dev/null
+++ b/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs
@@ -0,0 +1,561 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Updates;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Server.Implementations.Activity
+{
+ public class ActivityLogEntryPoint : IServerEntryPoint
+ {
+ private readonly IInstallationManager _installationManager;
+
+ //private readonly ILogManager _logManager;
+ //private readonly ILogger _logger;
+ private readonly ISessionManager _sessionManager;
+ private readonly ITaskManager _taskManager;
+ private readonly IActivityManager _activityManager;
+ private readonly ILocalizationManager _localization;
+
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISubtitleManager _subManager;
+ private readonly IUserManager _userManager;
+ private readonly IServerConfigurationManager _config;
+ private readonly IServerApplicationHost _appHost;
+
+ public ActivityLogEntryPoint(ISessionManager sessionManager, ITaskManager taskManager, IActivityManager activityManager, ILocalizationManager localization, IInstallationManager installationManager, ILibraryManager libraryManager, ISubtitleManager subManager, IUserManager userManager, IServerConfigurationManager config, IServerApplicationHost appHost)
+ {
+ //_logger = _logManager.GetLogger("ActivityLogEntryPoint");
+ _sessionManager = sessionManager;
+ _taskManager = taskManager;
+ _activityManager = activityManager;
+ _localization = localization;
+ _installationManager = installationManager;
+ _libraryManager = libraryManager;
+ _subManager = subManager;
+ _userManager = userManager;
+ _config = config;
+ //_logManager = logManager;
+ _appHost = appHost;
+ }
+
+ public void Run()
+ {
+ //_taskManager.TaskExecuting += _taskManager_TaskExecuting;
+ //_taskManager.TaskCompleted += _taskManager_TaskCompleted;
+
+ //_installationManager.PluginInstalled += _installationManager_PluginInstalled;
+ //_installationManager.PluginUninstalled += _installationManager_PluginUninstalled;
+ //_installationManager.PluginUpdated += _installationManager_PluginUpdated;
+
+ //_libraryManager.ItemAdded += _libraryManager_ItemAdded;
+ //_libraryManager.ItemRemoved += _libraryManager_ItemRemoved;
+
+ _sessionManager.SessionStarted += _sessionManager_SessionStarted;
+ _sessionManager.AuthenticationFailed += _sessionManager_AuthenticationFailed;
+ _sessionManager.AuthenticationSucceeded += _sessionManager_AuthenticationSucceeded;
+ _sessionManager.SessionEnded += _sessionManager_SessionEnded;
+
+ _sessionManager.PlaybackStart += _sessionManager_PlaybackStart;
+ _sessionManager.PlaybackStopped += _sessionManager_PlaybackStopped;
+
+ //_subManager.SubtitlesDownloaded += _subManager_SubtitlesDownloaded;
+ _subManager.SubtitleDownloadFailure += _subManager_SubtitleDownloadFailure;
+
+ _userManager.UserCreated += _userManager_UserCreated;
+ _userManager.UserPasswordChanged += _userManager_UserPasswordChanged;
+ _userManager.UserDeleted += _userManager_UserDeleted;
+ _userManager.UserConfigurationUpdated += _userManager_UserConfigurationUpdated;
+ _userManager.UserLockedOut += _userManager_UserLockedOut;
+
+ //_config.ConfigurationUpdated += _config_ConfigurationUpdated;
+ //_config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated;
+
+ //_logManager.LoggerLoaded += _logManager_LoggerLoaded;
+
+ _appHost.ApplicationUpdated += _appHost_ApplicationUpdated;
+ }
+
+ void _userManager_UserLockedOut(object sender, GenericEventArgs<User> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserLockedOutWithName"), e.Argument.Name),
+ Type = "UserLockedOut",
+ UserId = e.Argument.Id.ToString("N")
+ });
+ }
+
+ void _subManager_SubtitleDownloadFailure(object sender, SubtitleDownloadFailureEventArgs e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("SubtitleDownloadFailureForItem"), Notifications.Notifications.GetItemName(e.Item)),
+ Type = "SubtitleDownloadFailure",
+ ItemId = e.Item.Id.ToString("N"),
+ ShortOverview = string.Format(_localization.GetLocalizedString("ProviderValue"), e.Provider),
+ Overview = LogHelper.GetLogMessage(e.Exception).ToString()
+ });
+ }
+
+ void _sessionManager_PlaybackStopped(object sender, PlaybackStopEventArgs e)
+ {
+ var item = e.MediaInfo;
+
+ if (item == null)
+ {
+ //_logger.Warn("PlaybackStopped reported with null media info.");
+ return;
+ }
+
+ if (item.IsThemeMedia)
+ {
+ // Don't report theme song or local trailer playback
+ return;
+ }
+
+ if (e.Users.Count == 0)
+ {
+ return;
+ }
+
+ var user = e.Users.First();
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserStoppedPlayingItemWithValues"), user.Name, item.Name),
+ Type = "PlaybackStopped",
+ ShortOverview = string.Format(_localization.GetLocalizedString("AppDeviceValues"), e.ClientName, e.DeviceName),
+ UserId = user.Id.ToString("N")
+ });
+ }
+
+ void _sessionManager_PlaybackStart(object sender, PlaybackProgressEventArgs e)
+ {
+ var item = e.MediaInfo;
+
+ if (item == null)
+ {
+ //_logger.Warn("PlaybackStart reported with null media info.");
+ return;
+ }
+
+ if (item.IsThemeMedia)
+ {
+ // Don't report theme song or local trailer playback
+ return;
+ }
+
+ if (e.Users.Count == 0)
+ {
+ return;
+ }
+
+ var user = e.Users.First();
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserStartedPlayingItemWithValues"), user.Name, item.Name),
+ Type = "PlaybackStart",
+ ShortOverview = string.Format(_localization.GetLocalizedString("AppDeviceValues"), e.ClientName, e.DeviceName),
+ UserId = user.Id.ToString("N")
+ });
+ }
+
+ void _sessionManager_SessionEnded(object sender, SessionEventArgs e)
+ {
+ string name;
+ var session = e.SessionInfo;
+
+ if (string.IsNullOrWhiteSpace(session.UserName))
+ {
+ name = string.Format(_localization.GetLocalizedString("DeviceOfflineWithName"), session.DeviceName);
+
+ // Causing too much spam for now
+ return;
+ }
+ else
+ {
+ name = string.Format(_localization.GetLocalizedString("UserOfflineFromDevice"), session.UserName, session.DeviceName);
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = name,
+ Type = "SessionEnded",
+ ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), session.RemoteEndPoint),
+ UserId = session.UserId.HasValue ? session.UserId.Value.ToString("N") : null
+ });
+ }
+
+ void _sessionManager_AuthenticationSucceeded(object sender, GenericEventArgs<AuthenticationRequest> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("AuthenticationSucceededWithUserName"), e.Argument.Username),
+ Type = "AuthenticationSucceeded",
+ ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), e.Argument.RemoteEndPoint)
+ });
+ }
+
+ void _sessionManager_AuthenticationFailed(object sender, GenericEventArgs<AuthenticationRequest> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("FailedLoginAttemptWithUserName"), e.Argument.Username),
+ Type = "AuthenticationFailed",
+ ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), e.Argument.RemoteEndPoint),
+ Severity = LogSeverity.Error
+ });
+ }
+
+ void _appHost_ApplicationUpdated(object sender, GenericEventArgs<PackageVersionInfo> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = _localization.GetLocalizedString("MessageApplicationUpdated"),
+ Type = "ApplicationUpdated",
+ ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), e.Argument.versionStr),
+ Overview = e.Argument.description
+ });
+ }
+
+ void _logManager_LoggerLoaded(object sender, EventArgs e)
+ {
+ }
+
+ void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("MessageNamedServerConfigurationUpdatedWithValue"), e.Key),
+ Type = "NamedConfigurationUpdated"
+ });
+ }
+
+ void _config_ConfigurationUpdated(object sender, EventArgs e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = _localization.GetLocalizedString("MessageServerConfigurationUpdated"),
+ Type = "ServerConfigurationUpdated"
+ });
+ }
+
+ void _userManager_UserConfigurationUpdated(object sender, GenericEventArgs<User> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserConfigurationUpdatedWithName"), e.Argument.Name),
+ Type = "UserConfigurationUpdated",
+ UserId = e.Argument.Id.ToString("N")
+ });
+ }
+
+ void _userManager_UserDeleted(object sender, GenericEventArgs<User> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserDeletedWithName"), e.Argument.Name),
+ Type = "UserDeleted"
+ });
+ }
+
+ void _userManager_UserPasswordChanged(object sender, GenericEventArgs<User> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserPasswordChangedWithName"), e.Argument.Name),
+ Type = "UserPasswordChanged",
+ UserId = e.Argument.Id.ToString("N")
+ });
+ }
+
+ void _userManager_UserCreated(object sender, GenericEventArgs<User> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("UserCreatedWithName"), e.Argument.Name),
+ Type = "UserCreated",
+ UserId = e.Argument.Id.ToString("N")
+ });
+ }
+
+ void _subManager_SubtitlesDownloaded(object sender, SubtitleDownloadEventArgs e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("SubtitlesDownloadedForItem"), Notifications.Notifications.GetItemName(e.Item)),
+ Type = "SubtitlesDownloaded",
+ ItemId = e.Item.Id.ToString("N"),
+ ShortOverview = string.Format(_localization.GetLocalizedString("ProviderValue"), e.Provider)
+ });
+ }
+
+ void _sessionManager_SessionStarted(object sender, SessionEventArgs e)
+ {
+ string name;
+ var session = e.SessionInfo;
+
+ if (string.IsNullOrWhiteSpace(session.UserName))
+ {
+ name = string.Format(_localization.GetLocalizedString("DeviceOnlineWithName"), session.DeviceName);
+
+ // Causing too much spam for now
+ return;
+ }
+ else
+ {
+ name = string.Format(_localization.GetLocalizedString("UserOnlineFromDevice"), session.UserName, session.DeviceName);
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = name,
+ Type = "SessionStarted",
+ ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), session.RemoteEndPoint),
+ UserId = session.UserId.HasValue ? session.UserId.Value.ToString("N") : null
+ });
+ }
+
+ void _libraryManager_ItemRemoved(object sender, ItemChangeEventArgs e)
+ {
+ if (e.Item.SourceType != SourceType.Library)
+ {
+ return;
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("ItemRemovedWithName"), Notifications.Notifications.GetItemName(e.Item)),
+ Type = "ItemRemoved"
+ });
+ }
+
+ void _libraryManager_ItemAdded(object sender, ItemChangeEventArgs e)
+ {
+ if (e.Item.SourceType != SourceType.Library)
+ {
+ return;
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("ItemAddedWithName"), Notifications.Notifications.GetItemName(e.Item)),
+ Type = "ItemAdded",
+ ItemId = e.Item.Id.ToString("N")
+ });
+ }
+
+ void _installationManager_PluginUpdated(object sender, GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("PluginUpdatedWithName"), e.Argument.Item1.Name),
+ Type = "PluginUpdated",
+ ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), e.Argument.Item2.versionStr),
+ Overview = e.Argument.Item2.description
+ });
+ }
+
+ void _installationManager_PluginUninstalled(object sender, GenericEventArgs<IPlugin> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("PluginUninstalledWithName"), e.Argument.Name),
+ Type = "PluginUninstalled"
+ });
+ }
+
+ void _installationManager_PluginInstalled(object sender, GenericEventArgs<PackageVersionInfo> e)
+ {
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("PluginInstalledWithName"), e.Argument.name),
+ Type = "PluginInstalled",
+ ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), e.Argument.versionStr)
+ });
+ }
+
+ void _taskManager_TaskExecuting(object sender, GenericEventArgs<IScheduledTaskWorker> e)
+ {
+ var task = e.Argument;
+
+ var activityTask = task.ScheduledTask as IConfigurableScheduledTask;
+ if (activityTask != null && !activityTask.IsLogged)
+ {
+ return;
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("ScheduledTaskStartedWithName"), task.Name),
+ Type = "ScheduledTaskStarted"
+ });
+ }
+
+ void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e)
+ {
+ var result = e.Result;
+ var task = e.Task;
+
+ var activityTask = task.ScheduledTask as IConfigurableScheduledTask;
+ if (activityTask != null && !activityTask.IsLogged)
+ {
+ return;
+ }
+
+ var time = result.EndTimeUtc - result.StartTimeUtc;
+ var runningTime = string.Format(_localization.GetLocalizedString("LabelRunningTimeValue"), ToUserFriendlyString(time));
+
+ if (result.Status == TaskCompletionStatus.Failed)
+ {
+ var vals = new List<string>();
+
+ if (!string.IsNullOrWhiteSpace(e.Result.ErrorMessage))
+ {
+ vals.Add(e.Result.ErrorMessage);
+ }
+ if (!string.IsNullOrWhiteSpace(e.Result.LongErrorMessage))
+ {
+ vals.Add(e.Result.LongErrorMessage);
+ }
+
+ CreateLogEntry(new ActivityLogEntry
+ {
+ Name = string.Format(_localization.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name),
+ Type = "ScheduledTaskFailed",
+ Overview = string.Join(Environment.NewLine, vals.ToArray()),
+ ShortOverview = runningTime,
+ Severity = LogSeverity.Error
+ });
+ }
+ }
+
+ private async void CreateLogEntry(ActivityLogEntry entry)
+ {
+ try
+ {
+ await _activityManager.Create(entry).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Logged at lower levels
+ }
+ }
+
+ public void Dispose()
+ {
+ _taskManager.TaskExecuting -= _taskManager_TaskExecuting;
+ _taskManager.TaskCompleted -= _taskManager_TaskCompleted;
+
+ _installationManager.PluginInstalled -= _installationManager_PluginInstalled;
+ _installationManager.PluginUninstalled -= _installationManager_PluginUninstalled;
+ _installationManager.PluginUpdated -= _installationManager_PluginUpdated;
+
+ _libraryManager.ItemAdded -= _libraryManager_ItemAdded;
+ _libraryManager.ItemRemoved -= _libraryManager_ItemRemoved;
+
+ _sessionManager.SessionStarted -= _sessionManager_SessionStarted;
+ _sessionManager.AuthenticationFailed -= _sessionManager_AuthenticationFailed;
+ _sessionManager.AuthenticationSucceeded -= _sessionManager_AuthenticationSucceeded;
+ _sessionManager.SessionEnded -= _sessionManager_SessionEnded;
+
+ _sessionManager.PlaybackStart -= _sessionManager_PlaybackStart;
+ _sessionManager.PlaybackStopped -= _sessionManager_PlaybackStopped;
+
+ _subManager.SubtitlesDownloaded -= _subManager_SubtitlesDownloaded;
+ _subManager.SubtitleDownloadFailure -= _subManager_SubtitleDownloadFailure;
+
+ _userManager.UserCreated -= _userManager_UserCreated;
+ _userManager.UserPasswordChanged -= _userManager_UserPasswordChanged;
+ _userManager.UserDeleted -= _userManager_UserDeleted;
+ _userManager.UserConfigurationUpdated -= _userManager_UserConfigurationUpdated;
+ _userManager.UserLockedOut -= _userManager_UserLockedOut;
+
+ _config.ConfigurationUpdated -= _config_ConfigurationUpdated;
+ _config.NamedConfigurationUpdated -= _config_NamedConfigurationUpdated;
+
+ //_logManager.LoggerLoaded -= _logManager_LoggerLoaded;
+
+ _appHost.ApplicationUpdated -= _appHost_ApplicationUpdated;
+ }
+
+ /// <summary>
+ /// Constructs a user-friendly string for this TimeSpan instance.
+ /// </summary>
+ public static string ToUserFriendlyString(TimeSpan span)
+ {
+ const int DaysInYear = 365;
+ const int DaysInMonth = 30;
+
+ // Get each non-zero value from TimeSpan component
+ List<string> values = new List<string>();
+
+ // Number of years
+ int days = span.Days;
+ if (days >= DaysInYear)
+ {
+ int years = days / DaysInYear;
+ values.Add(CreateValueString(years, "year"));
+ days = days % DaysInYear;
+ }
+ // Number of months
+ if (days >= DaysInMonth)
+ {
+ int months = days / DaysInMonth;
+ values.Add(CreateValueString(months, "month"));
+ days = days % DaysInMonth;
+ }
+ // Number of days
+ if (days >= 1)
+ values.Add(CreateValueString(days, "day"));
+ // Number of hours
+ if (span.Hours >= 1)
+ values.Add(CreateValueString(span.Hours, "hour"));
+ // Number of minutes
+ if (span.Minutes >= 1)
+ values.Add(CreateValueString(span.Minutes, "minute"));
+ // Number of seconds (include when 0 if no other components included)
+ if (span.Seconds >= 1 || values.Count == 0)
+ values.Add(CreateValueString(span.Seconds, "second"));
+
+ // Combine values into string
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < values.Count; i++)
+ {
+ if (builder.Length > 0)
+ builder.Append(i == values.Count - 1 ? " and " : ", ");
+ builder.Append(values[i]);
+ }
+ // Return result
+ return builder.ToString();
+ }
+
+ /// <summary>
+ /// Constructs a string description of a time-span value.
+ /// </summary>
+ /// <param name="value">The value of this item</param>
+ /// <param name="description">The name of this item (singular form)</param>
+ private static string CreateValueString(int value, string description)
+ {
+ return String.Format("{0:#,##0} {1}",
+ value, value == 1 ? description : String.Format("{0}s", description));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Activity/ActivityManager.cs b/Emby.Server.Implementations/Activity/ActivityManager.cs
new file mode 100644
index 000000000..b6095f082
--- /dev/null
+++ b/Emby.Server.Implementations/Activity/ActivityManager.cs
@@ -0,0 +1,56 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Activity
+{
+ public class ActivityManager : IActivityManager
+ {
+ public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated;
+
+ private readonly IActivityRepository _repo;
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+
+ public ActivityManager(ILogger logger, IActivityRepository repo, IUserManager userManager)
+ {
+ _logger = logger;
+ _repo = repo;
+ _userManager = userManager;
+ }
+
+ public async Task Create(ActivityLogEntry entry)
+ {
+ entry.Id = Guid.NewGuid().ToString("N");
+ entry.Date = DateTime.UtcNow;
+
+ await _repo.Create(entry).ConfigureAwait(false);
+
+ EventHelper.FireEventIfNotNull(EntryCreated, this, new GenericEventArgs<ActivityLogEntry>(entry), _logger);
+ }
+
+ public QueryResult<ActivityLogEntry> GetActivityLogEntries(DateTime? minDate, int? startIndex, int? limit)
+ {
+ var result = _repo.GetActivityLogEntries(minDate, startIndex, limit);
+
+ foreach (var item in result.Items.Where(i => !string.IsNullOrWhiteSpace(i.UserId)))
+ {
+ var user = _userManager.GetUserById(item.UserId);
+
+ if (user != null)
+ {
+ var dto = _userManager.GetUserDto(user);
+ item.UserPrimaryImageTag = dto.PrimaryImageTag;
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Activity/ActivityRepository.cs b/Emby.Server.Implementations/Activity/ActivityRepository.cs
new file mode 100644
index 000000000..bf8835846
--- /dev/null
+++ b/Emby.Server.Implementations/Activity/ActivityRepository.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Activity
+{
+ public class ActivityRepository : BaseSqliteRepository, IActivityRepository
+ {
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ public ActivityRepository(ILogger logger, IServerApplicationPaths appPaths)
+ : base(logger)
+ {
+ DbFilePath = Path.Combine(appPaths.DataPath, "activitylog.db");
+ }
+
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+ "create table if not exists ActivityLogEntries (Id GUID PRIMARY KEY, Name TEXT, Overview TEXT, ShortOverview TEXT, Type TEXT, ItemId TEXT, UserId TEXT, DateCreated DATETIME, LogSeverity TEXT)",
+ "create index if not exists idx_ActivityLogEntries on ActivityLogEntries(Id)"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ private const string BaseActivitySelectText = "select Id, Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity from ActivityLogEntries";
+
+ public Task Create(ActivityLogEntry entry)
+ {
+ return Update(entry);
+ }
+
+ public async Task Update(ActivityLogEntry entry)
+ {
+ if (entry == null)
+ {
+ throw new ArgumentNullException("entry");
+ }
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("replace into ActivityLogEntries (Id, Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity) values (@Id, @Name, @Overview, @ShortOverview, @Type, @ItemId, @UserId, @DateCreated, @LogSeverity)"))
+ {
+ statement.TryBind("@Id", entry.Id.ToGuidParamValue());
+ statement.TryBind("@Name", entry.Name);
+
+ statement.TryBind("@Overview", entry.Overview);
+ statement.TryBind("@ShortOverview", entry.ShortOverview);
+ statement.TryBind("@Type", entry.Type);
+ statement.TryBind("@ItemId", entry.ItemId);
+ statement.TryBind("@UserId", entry.UserId);
+ statement.TryBind("@DateCreated", entry.Date.ToDateTimeParamValue());
+ statement.TryBind("@LogSeverity", entry.Severity.ToString());
+
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public QueryResult<ActivityLogEntry> GetActivityLogEntries(DateTime? minDate, int? startIndex, int? limit)
+ {
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = BaseActivitySelectText;
+ var whereClauses = new List<string>();
+
+ if (minDate.HasValue)
+ {
+ whereClauses.Add("DateCreated>=@DateCreated");
+ }
+
+ var whereTextWithoutPaging = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ if (startIndex.HasValue && startIndex.Value > 0)
+ {
+ var pagingWhereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ whereClauses.Add(string.Format("Id NOT IN (SELECT Id FROM ActivityLogEntries {0} ORDER BY DateCreated DESC LIMIT {1})",
+ pagingWhereText,
+ startIndex.Value.ToString(_usCulture)));
+ }
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ commandText += " ORDER BY DateCreated DESC";
+
+ if (limit.HasValue)
+ {
+ commandText += " LIMIT " + limit.Value.ToString(_usCulture);
+ }
+
+ var statementTexts = new List<string>();
+ statementTexts.Add(commandText);
+ statementTexts.Add("select count (Id) from ActivityLogEntries" + whereTextWithoutPaging);
+
+ return connection.RunInTransaction(db =>
+ {
+ var list = new List<ActivityLogEntry>();
+ var result = new QueryResult<ActivityLogEntry>();
+
+ var statements = PrepareAllSafe(db, statementTexts).ToList();
+
+ using (var statement = statements[0])
+ {
+ if (minDate.HasValue)
+ {
+ statement.TryBind("@DateCreated", minDate.Value.ToDateTimeParamValue());
+ }
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(GetEntry(row));
+ }
+ }
+
+ using (var statement = statements[1])
+ {
+ if (minDate.HasValue)
+ {
+ statement.TryBind("@DateCreated", minDate.Value.ToDateTimeParamValue());
+ }
+
+ result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First();
+ }
+
+ result.Items = list.ToArray();
+ return result;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ private ActivityLogEntry GetEntry(IReadOnlyList<IResultSetValue> reader)
+ {
+ var index = 0;
+
+ var info = new ActivityLogEntry
+ {
+ Id = reader[index].ReadGuid().ToString("N")
+ };
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.Name = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.Overview = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.ShortOverview = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.Type = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.ItemId = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.UserId = reader[index].ToString();
+ }
+
+ index++;
+ info.Date = reader[index].ReadDateTime();
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ info.Severity = (LogSeverity)Enum.Parse(typeof(LogSeverity), reader[index].ToString(), true);
+ }
+
+ return info;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs b/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs
new file mode 100644
index 000000000..a29f55f16
--- /dev/null
+++ b/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs
@@ -0,0 +1,21 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Branding;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Branding
+{
+ public class BrandingConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new[]
+ {
+ new ConfigurationStore
+ {
+ ConfigurationType = typeof(BrandingOptions),
+ Key = "branding"
+ }
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs
new file mode 100644
index 000000000..05cde91e2
--- /dev/null
+++ b/Emby.Server.Implementations/Browser/BrowserLauncher.cs
@@ -0,0 +1,73 @@
+using MediaBrowser.Controller;
+using System;
+
+namespace Emby.Server.Implementations.Browser
+{
+ /// <summary>
+ /// Class BrowserLauncher
+ /// </summary>
+ public static class BrowserLauncher
+ {
+ /// <summary>
+ /// Opens the dashboard page.
+ /// </summary>
+ /// <param name="page">The page.</param>
+ /// <param name="appHost">The app host.</param>
+ public static void OpenDashboardPage(string page, IServerApplicationHost appHost)
+ {
+ var url = appHost.GetLocalApiUrl("localhost") + "/web/" + page;
+
+ OpenUrl(appHost, url);
+ }
+
+ /// <summary>
+ /// Opens the community.
+ /// </summary>
+ public static void OpenCommunity(IServerApplicationHost appHost)
+ {
+ OpenUrl(appHost, "http://emby.media/community");
+ }
+
+ public static void OpenEmbyPremiere(IServerApplicationHost appHost)
+ {
+ OpenDashboardPage("supporterkey.html", appHost);
+ }
+
+ /// <summary>
+ /// Opens the web client.
+ /// </summary>
+ /// <param name="appHost">The app host.</param>
+ public static void OpenWebClient(IServerApplicationHost appHost)
+ {
+ OpenDashboardPage("index.html", appHost);
+ }
+
+ /// <summary>
+ /// Opens the dashboard.
+ /// </summary>
+ /// <param name="appHost">The app host.</param>
+ public static void OpenDashboard(IServerApplicationHost appHost)
+ {
+ OpenDashboardPage("dashboard.html", appHost);
+ }
+
+ /// <summary>
+ /// Opens the URL.
+ /// </summary>
+ /// <param name="url">The URL.</param>
+ private static void OpenUrl(IServerApplicationHost appHost, string url)
+ {
+ try
+ {
+ appHost.LaunchUrl(url);
+ }
+ catch (NotImplementedException)
+ {
+
+ }
+ catch (Exception)
+ {
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Channels/ChannelConfigurations.cs b/Emby.Server.Implementations/Channels/ChannelConfigurations.cs
new file mode 100644
index 000000000..ef0973e7f
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/ChannelConfigurations.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Channels
+{
+ public static class ChannelConfigurationExtension
+ {
+ public static ChannelOptions GetChannelsConfiguration(this IConfigurationManager manager)
+ {
+ return manager.GetConfiguration<ChannelOptions>("channels");
+ }
+ }
+
+ public class ChannelConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ Key = "channels",
+ ConfigurationType = typeof (ChannelOptions)
+ }
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs b/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs
new file mode 100644
index 000000000..98011ddd4
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs
@@ -0,0 +1,43 @@
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Channels
+{
+ public class ChannelDynamicMediaSourceProvider : IMediaSourceProvider
+ {
+ private readonly ChannelManager _channelManager;
+
+ public ChannelDynamicMediaSourceProvider(IChannelManager channelManager)
+ {
+ _channelManager = (ChannelManager)channelManager;
+ }
+
+ public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var baseItem = (BaseItem) item;
+
+ if (baseItem.SourceType == SourceType.Channel)
+ {
+ return _channelManager.GetDynamicMediaSources(baseItem, cancellationToken);
+ }
+
+ return Task.FromResult<IEnumerable<MediaSourceInfo>>(new List<MediaSourceInfo>());
+ }
+
+ public Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> OpenMediaSource(string openToken, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CloseMediaSource(string liveStreamId)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Channels/ChannelImageProvider.cs b/Emby.Server.Implementations/Channels/ChannelImageProvider.cs
new file mode 100644
index 000000000..f892b1e62
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/ChannelImageProvider.cs
@@ -0,0 +1,55 @@
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Channels
+{
+ public class ChannelImageProvider : IDynamicImageProvider, IHasItemChangeMonitor
+ {
+ private readonly IChannelManager _channelManager;
+
+ public ChannelImageProvider(IChannelManager channelManager)
+ {
+ _channelManager = channelManager;
+ }
+
+ public IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return GetChannel(item).GetSupportedChannelImages();
+ }
+
+ public Task<DynamicImageResponse> GetImage(IHasImages item, ImageType type, CancellationToken cancellationToken)
+ {
+ var channel = GetChannel(item);
+
+ return channel.GetChannelImage(type, cancellationToken);
+ }
+
+ public string Name
+ {
+ get { return "Channel Image Provider"; }
+ }
+
+ public bool Supports(IHasImages item)
+ {
+ return item is Channel;
+ }
+
+ private IChannel GetChannel(IHasImages item)
+ {
+ var channel = (Channel)item;
+
+ return ((ChannelManager)_channelManager).GetChannelProvider(channel);
+ }
+
+ public bool HasChanged(IHasMetadata item, IDirectoryService directoryService)
+ {
+ return GetSupportedImages(item).Any(i => !item.HasImage(i));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs
new file mode 100644
index 000000000..0df916ded
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/ChannelManager.cs
@@ -0,0 +1,1645 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Server.Implementations.Channels
+{
+ public class ChannelManager : IChannelManager
+ {
+ private IChannel[] _channels;
+
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly IDtoService _dtoService;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IServerConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IHttpClient _httpClient;
+ private readonly IProviderManager _providerManager;
+
+ private readonly ILocalizationManager _localization;
+ private readonly ConcurrentDictionary<Guid, bool> _refreshedItems = new ConcurrentDictionary<Guid, bool>();
+
+ public ChannelManager(IUserManager userManager, IDtoService dtoService, ILibraryManager libraryManager, ILogger logger, IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, IJsonSerializer jsonSerializer, ILocalizationManager localization, IHttpClient httpClient, IProviderManager providerManager)
+ {
+ _userManager = userManager;
+ _dtoService = dtoService;
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _config = config;
+ _fileSystem = fileSystem;
+ _userDataManager = userDataManager;
+ _jsonSerializer = jsonSerializer;
+ _localization = localization;
+ _httpClient = httpClient;
+ _providerManager = providerManager;
+ }
+
+ private TimeSpan CacheLength
+ {
+ get
+ {
+ return TimeSpan.FromHours(6);
+ }
+ }
+
+ public void AddParts(IEnumerable<IChannel> channels)
+ {
+ _channels = channels.ToArray();
+ }
+
+ public string ChannelDownloadPath
+ {
+ get
+ {
+ var options = _config.GetChannelsConfiguration();
+
+ if (!string.IsNullOrWhiteSpace(options.DownloadPath))
+ {
+ return options.DownloadPath;
+ }
+
+ return Path.Combine(_config.ApplicationPaths.ProgramDataPath, "channels");
+ }
+ }
+
+ private IEnumerable<IChannel> GetAllChannels()
+ {
+ return _channels
+ .OrderBy(i => i.Name);
+ }
+
+ public IEnumerable<Guid> GetInstalledChannelIds()
+ {
+ return GetAllChannels().Select(i => GetInternalChannelId(i.Name));
+ }
+
+ public Task<QueryResult<Channel>> GetChannelsInternal(ChannelQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ var channels = GetAllChannels()
+ .Select(GetChannelEntity)
+ .OrderBy(i => i.SortName)
+ .ToList();
+
+ if (query.SupportsLatestItems.HasValue)
+ {
+ var val = query.SupportsLatestItems.Value;
+ channels = channels.Where(i =>
+ {
+ try
+ {
+ return GetChannelProvider(i) is ISupportsLatestMedia == val;
+ }
+ catch
+ {
+ return false;
+ }
+
+ }).ToList();
+ }
+ if (query.IsFavorite.HasValue)
+ {
+ var val = query.IsFavorite.Value;
+ channels = channels.Where(i => _userDataManager.GetUserData(user, i).IsFavorite == val)
+ .ToList();
+ }
+
+ if (user != null)
+ {
+ channels = channels.Where(i =>
+ {
+ if (!i.IsVisible(user))
+ {
+ return false;
+ }
+
+ try
+ {
+ return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N"));
+ }
+ catch
+ {
+ return false;
+ }
+
+ }).ToList();
+ }
+
+ var all = channels;
+ var totalCount = all.Count;
+
+ if (query.StartIndex.HasValue)
+ {
+ all = all.Skip(query.StartIndex.Value).ToList();
+ }
+ if (query.Limit.HasValue)
+ {
+ all = all.Take(query.Limit.Value).ToList();
+ }
+
+ var returnItems = all.ToArray();
+
+ var result = new QueryResult<Channel>
+ {
+ Items = returnItems,
+ TotalRecordCount = totalCount
+ };
+
+ return Task.FromResult(result);
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetChannels(ChannelQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ var internalResult = await GetChannelsInternal(query, cancellationToken).ConfigureAwait(false);
+
+ var dtoOptions = new DtoOptions();
+
+ var returnItems = (await _dtoService.GetBaseItemDtos(internalResult.Items, dtoOptions, user).ConfigureAwait(false))
+ .ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnItems,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ public async Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ _refreshedItems.Clear();
+
+ var allChannelsList = GetAllChannels().ToList();
+
+ var numComplete = 0;
+
+ foreach (var channelInfo in allChannelsList)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ await GetChannel(channelInfo, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channel information for {0}", ex, channelInfo.Name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= allChannelsList.Count;
+
+ progress.Report(100 * percent);
+ }
+
+ progress.Report(100);
+ }
+
+ private Channel GetChannelEntity(IChannel channel)
+ {
+ var item = GetChannel(GetInternalChannelId(channel.Name).ToString("N"));
+
+ if (item == null)
+ {
+ item = GetChannel(channel, CancellationToken.None).Result;
+ }
+
+ return item;
+ }
+
+ private List<ChannelMediaInfo> GetSavedMediaSources(BaseItem item)
+ {
+ var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasources.json");
+
+ try
+ {
+ return _jsonSerializer.DeserializeFromFile<List<ChannelMediaInfo>>(path) ?? new List<ChannelMediaInfo>();
+ }
+ catch
+ {
+ return new List<ChannelMediaInfo>();
+ }
+ }
+
+ private void SaveMediaSources(BaseItem item, List<ChannelMediaInfo> mediaSources)
+ {
+ var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasources.json");
+
+ if (mediaSources == null || mediaSources.Count == 0)
+ {
+ try
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ catch
+ {
+
+ }
+ return;
+ }
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ _jsonSerializer.SerializeToFile(mediaSources, path);
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetStaticMediaSources(BaseItem item, bool includeCachedVersions, CancellationToken cancellationToken)
+ {
+ IEnumerable<ChannelMediaInfo> results = new List<ChannelMediaInfo>();
+ var video = item as Video;
+ if (video != null)
+ {
+ results = video.ChannelMediaSources;
+ }
+ var audio = item as Audio;
+ if (audio != null)
+ {
+ results = audio.ChannelMediaSources ?? GetSavedMediaSources(audio);
+ }
+
+ var sources = SortMediaInfoResults(results)
+ .Select(i => GetMediaSource(item, i))
+ .ToList();
+
+ if (includeCachedVersions)
+ {
+ var cachedVersions = GetCachedChannelItemMediaSources(item);
+ sources.InsertRange(0, cachedVersions);
+ }
+
+ return sources;
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(BaseItem item, CancellationToken cancellationToken)
+ {
+ var channel = GetChannel(item.ChannelId);
+ var channelPlugin = GetChannelProvider(channel);
+
+ var requiresCallback = channelPlugin as IRequiresMediaInfoCallback;
+
+ IEnumerable<ChannelMediaInfo> results;
+
+ if (requiresCallback != null)
+ {
+ results = await GetChannelItemMediaSourcesInternal(requiresCallback, GetItemExternalId(item), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ results = new List<ChannelMediaInfo>();
+ }
+
+ var list = SortMediaInfoResults(results)
+ .Select(i => GetMediaSource(item, i))
+ .ToList();
+
+ var cachedVersions = GetCachedChannelItemMediaSources(item);
+ list.InsertRange(0, cachedVersions);
+
+ return list;
+ }
+
+ private readonly ConcurrentDictionary<string, Tuple<DateTime, List<ChannelMediaInfo>>> _channelItemMediaInfo =
+ new ConcurrentDictionary<string, Tuple<DateTime, List<ChannelMediaInfo>>>();
+
+ private async Task<IEnumerable<ChannelMediaInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken)
+ {
+ Tuple<DateTime, List<ChannelMediaInfo>> cachedInfo;
+
+ if (_channelItemMediaInfo.TryGetValue(id, out cachedInfo))
+ {
+ if ((DateTime.UtcNow - cachedInfo.Item1).TotalMinutes < 5)
+ {
+ return cachedInfo.Item2;
+ }
+ }
+
+ var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken)
+ .ConfigureAwait(false);
+ var list = mediaInfo.ToList();
+
+ var item2 = new Tuple<DateTime, List<ChannelMediaInfo>>(DateTime.UtcNow, list);
+ _channelItemMediaInfo.AddOrUpdate(id, item2, (key, oldValue) => item2);
+
+ return list;
+ }
+
+ private IEnumerable<MediaSourceInfo> GetCachedChannelItemMediaSources(BaseItem item)
+ {
+ var filenamePrefix = item.Id.ToString("N");
+ var parentPath = Path.Combine(ChannelDownloadPath, item.ChannelId);
+
+ try
+ {
+ var files = _fileSystem.GetFiles(parentPath);
+
+ if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
+ {
+ files = files.Where(i => _libraryManager.IsVideoFile(i.FullName));
+ }
+ else
+ {
+ files = files.Where(i => _libraryManager.IsAudioFile(i.FullName));
+ }
+
+ var file = files
+ .FirstOrDefault(i => i.Name.StartsWith(filenamePrefix, StringComparison.OrdinalIgnoreCase));
+
+ if (file != null)
+ {
+ var cachedItem = _libraryManager.ResolvePath(file);
+
+ if (cachedItem != null)
+ {
+ var hasMediaSources = _libraryManager.GetItemById(cachedItem.Id) as IHasMediaSources;
+
+ if (hasMediaSources != null)
+ {
+ var source = hasMediaSources.GetMediaSources(true).FirstOrDefault();
+
+ if (source != null)
+ {
+ return new[] { source };
+ }
+ }
+ }
+ }
+ }
+ catch (IOException)
+ {
+
+ }
+
+ return new List<MediaSourceInfo>();
+ }
+
+ private MediaSourceInfo GetMediaSource(BaseItem item, ChannelMediaInfo info)
+ {
+ var source = info.ToMediaSource();
+
+ source.RunTimeTicks = source.RunTimeTicks ?? item.RunTimeTicks;
+
+ return source;
+ }
+
+ private IEnumerable<ChannelMediaInfo> SortMediaInfoResults(IEnumerable<ChannelMediaInfo> channelMediaSources)
+ {
+ var list = channelMediaSources.ToList();
+
+ var options = _config.GetChannelsConfiguration();
+
+ var width = options.PreferredStreamingWidth;
+
+ if (width.HasValue)
+ {
+ var val = width.Value;
+
+ var res = list
+ .OrderBy(i => i.Width.HasValue && i.Width.Value <= val ? 0 : 1)
+ .ThenBy(i => Math.Abs((i.Width ?? 0) - val))
+ .ThenByDescending(i => i.Width ?? 0)
+ .ThenBy(list.IndexOf)
+ .ToList();
+
+
+ return res;
+ }
+
+ return list
+ .OrderByDescending(i => i.Width ?? 0)
+ .ThenBy(list.IndexOf);
+ }
+
+ private async Task<Channel> GetChannel(IChannel channelInfo, CancellationToken cancellationToken)
+ {
+ var parentFolder = await GetInternalChannelFolder(cancellationToken).ConfigureAwait(false);
+ var parentFolderId = parentFolder.Id;
+
+ var id = GetInternalChannelId(channelInfo.Name);
+ var idString = id.ToString("N");
+
+ var path = Channel.GetInternalMetadataPath(_config.ApplicationPaths.InternalMetadataPath, id);
+
+ var isNew = false;
+ var forceUpdate = false;
+
+ var item = _libraryManager.GetItemById(id) as Channel;
+
+ if (item == null)
+ {
+ item = new Channel
+ {
+ Name = channelInfo.Name,
+ Id = id,
+ DateCreated = _fileSystem.GetCreationTimeUtc(path),
+ DateModified = _fileSystem.GetLastWriteTimeUtc(path)
+ };
+
+ isNew = true;
+ }
+
+ if (!string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ isNew = true;
+ }
+ item.Path = path;
+
+ if (!string.Equals(item.ChannelId, idString, StringComparison.OrdinalIgnoreCase))
+ {
+ forceUpdate = true;
+ }
+ item.ChannelId = idString;
+
+ if (item.ParentId != parentFolderId)
+ {
+ forceUpdate = true;
+ }
+ item.ParentId = parentFolderId;
+
+ item.OfficialRating = GetOfficialRating(channelInfo.ParentalRating);
+ item.Overview = channelInfo.Description;
+ item.HomePageUrl = channelInfo.HomePageUrl;
+
+ if (string.IsNullOrWhiteSpace(item.Name))
+ {
+ item.Name = channelInfo.Name;
+ }
+
+ if (isNew)
+ {
+ await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false);
+ }
+ else if (forceUpdate)
+ {
+ await item.UpdateToRepository(ItemUpdateType.None, cancellationToken).ConfigureAwait(false);
+ }
+
+ await item.RefreshMetadata(new MetadataRefreshOptions(_fileSystem), cancellationToken);
+ return item;
+ }
+
+ private string GetOfficialRating(ChannelParentalRating rating)
+ {
+ switch (rating)
+ {
+ case ChannelParentalRating.Adult:
+ return "XXX";
+ case ChannelParentalRating.UsR:
+ return "R";
+ case ChannelParentalRating.UsPG13:
+ return "PG-13";
+ case ChannelParentalRating.UsPG:
+ return "PG";
+ default:
+ return null;
+ }
+ }
+
+ public Channel GetChannel(string id)
+ {
+ return _libraryManager.GetItemById(id) as Channel;
+ }
+
+ public IEnumerable<ChannelFeatures> GetAllChannelFeatures()
+ {
+ return _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Channel).Name },
+ SortBy = new[] { ItemSortBy.SortName }
+
+ }).Select(i => GetChannelFeatures(i.Id.ToString("N")));
+ }
+
+ public ChannelFeatures GetChannelFeatures(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ var channel = GetChannel(id);
+ var channelProvider = GetChannelProvider(channel);
+
+ return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures());
+ }
+
+ public bool SupportsSync(string channelId)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ //var channel = GetChannel(channelId);
+ var channelProvider = GetChannelProvider(channelId);
+
+ return channelProvider.GetChannelFeatures().SupportsContentDownloading;
+ }
+
+ public ChannelFeatures GetChannelFeaturesDto(Channel channel,
+ IChannel provider,
+ InternalChannelFeatures features)
+ {
+ var isIndexable = provider is IIndexableChannel;
+ var supportsLatest = provider is ISupportsLatestMedia;
+
+ return new ChannelFeatures
+ {
+ CanFilter = !features.MaxPageSize.HasValue,
+ CanSearch = provider is ISearchableChannel,
+ ContentTypes = features.ContentTypes,
+ DefaultSortFields = features.DefaultSortFields,
+ MaxPageSize = features.MaxPageSize,
+ MediaTypes = features.MediaTypes,
+ SupportsSortOrderToggle = features.SupportsSortOrderToggle,
+ SupportsLatestMedia = supportsLatest,
+ Name = channel.Name,
+ Id = channel.Id.ToString("N"),
+ SupportsContentDownloading = features.SupportsContentDownloading && (isIndexable || supportsLatest),
+ AutoRefreshLevels = features.AutoRefreshLevels
+ };
+ }
+
+ private Guid GetInternalChannelId(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+ return _libraryManager.GetNewItemId("Channel " + name, typeof(Channel));
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetLatestChannelItems(AllChannelMediaQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ var limit = query.Limit;
+
+ // See below about parental control
+ if (user != null)
+ {
+ query.StartIndex = null;
+ query.Limit = null;
+ }
+
+ var internalResult = await GetLatestChannelItemsInternal(query, cancellationToken).ConfigureAwait(false);
+
+ var items = internalResult.Items;
+ var totalRecordCount = internalResult.TotalRecordCount;
+
+ // Supporting parental control is a hack because it has to be done after querying the remote data source
+ // This will get screwy if apps try to page, so limit to 10 results in an attempt to always keep them on the first page
+ if (user != null)
+ {
+ items = items.Where(i => i.IsVisible(user))
+ .Take(limit ?? 10)
+ .ToArray();
+
+ totalRecordCount = items.Length;
+ }
+
+ var dtoOptions = new DtoOptions();
+
+ var returnItems = (await _dtoService.GetBaseItemDtos(items, dtoOptions, user).ConfigureAwait(false))
+ .ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnItems,
+ TotalRecordCount = totalRecordCount
+ };
+
+ return result;
+ }
+
+ public async Task<QueryResult<BaseItem>> GetLatestChannelItemsInternal(AllChannelMediaQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ if (!string.IsNullOrWhiteSpace(query.UserId) && user == null)
+ {
+ throw new ArgumentException("User not found.");
+ }
+
+ var channels = GetAllChannels();
+
+ if (query.ChannelIds.Length > 0)
+ {
+ // Avoid implicitly captured closure
+ var ids = query.ChannelIds;
+ channels = channels
+ .Where(i => ids.Contains(GetInternalChannelId(i.Name).ToString("N")))
+ .ToArray();
+ }
+
+ // Avoid implicitly captured closure
+ var userId = query.UserId;
+
+ var tasks = channels
+ .Select(async i =>
+ {
+ var indexable = i as ISupportsLatestMedia;
+
+ if (indexable != null)
+ {
+ try
+ {
+ var result = await GetLatestItems(indexable, i, userId, cancellationToken).ConfigureAwait(false);
+
+ var resultItems = result.ToList();
+
+ return new Tuple<IChannel, ChannelItemResult>(i, new ChannelItemResult
+ {
+ Items = resultItems,
+ TotalRecordCount = resultItems.Count
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting all media from {0}", ex, i.Name);
+ }
+ }
+ return new Tuple<IChannel, ChannelItemResult>(i, new ChannelItemResult());
+ });
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ var totalCount = results.Length;
+
+ IEnumerable<Tuple<IChannel, ChannelItemInfo>> items = results
+ .SelectMany(i => i.Item2.Items.Select(m => new Tuple<IChannel, ChannelItemInfo>(i.Item1, m)));
+
+ if (query.ContentTypes.Length > 0)
+ {
+ // Avoid implicitly captured closure
+ var contentTypes = query.ContentTypes;
+
+ items = items.Where(i => contentTypes.Contains(i.Item2.ContentType));
+ }
+ if (query.ExtraTypes.Length > 0)
+ {
+ // Avoid implicitly captured closure
+ var contentTypes = query.ExtraTypes;
+
+ items = items.Where(i => contentTypes.Contains(i.Item2.ExtraType));
+ }
+
+ // Avoid implicitly captured closure
+ var token = cancellationToken;
+ var itemTasks = items.Select(i =>
+ {
+ var channelProvider = i.Item1;
+ var internalChannelId = GetInternalChannelId(channelProvider.Name);
+ return GetChannelItemEntity(i.Item2, channelProvider, internalChannelId, token);
+ });
+
+ var internalItems = await Task.WhenAll(itemTasks).ConfigureAwait(false);
+
+ internalItems = ApplyFilters(internalItems, query.Filters, user).ToArray();
+ RefreshIfNeeded(internalItems);
+
+ if (query.StartIndex.HasValue)
+ {
+ internalItems = internalItems.Skip(query.StartIndex.Value).ToArray();
+ }
+ if (query.Limit.HasValue)
+ {
+ internalItems = internalItems.Take(query.Limit.Value).ToArray();
+ }
+
+ var returnItemArray = internalItems.ToArray();
+
+ return new QueryResult<BaseItem>
+ {
+ TotalRecordCount = totalCount,
+ Items = returnItemArray
+ };
+ }
+
+ private async Task<IEnumerable<ChannelItemInfo>> GetLatestItems(ISupportsLatestMedia indexable, IChannel channel, string userId, CancellationToken cancellationToken)
+ {
+ var cacheLength = CacheLength;
+ var cachePath = GetChannelDataCachePath(channel, userId, "channelmanager-latest", null, false);
+
+ try
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ return _jsonSerializer.DeserializeFromFile<List<ChannelItemInfo>>(cachePath);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ try
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ return _jsonSerializer.DeserializeFromFile<List<ChannelItemInfo>>(cachePath);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ var result = await indexable.GetLatestMedia(new ChannelLatestMediaSearch
+ {
+ UserId = userId
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ var resultItems = result.ToList();
+
+ CacheResponse(resultItems, cachePath);
+
+ return resultItems;
+ }
+ finally
+ {
+ _resourcePool.Release();
+ }
+ }
+
+ public async Task<QueryResult<BaseItem>> GetAllMediaInternal(AllChannelMediaQuery query, CancellationToken cancellationToken)
+ {
+ var channels = GetAllChannels();
+
+ if (query.ChannelIds.Length > 0)
+ {
+ // Avoid implicitly captured closure
+ var ids = query.ChannelIds;
+ channels = channels
+ .Where(i => ids.Contains(GetInternalChannelId(i.Name).ToString("N")))
+ .ToArray();
+ }
+
+ var tasks = channels
+ .Select(async i =>
+ {
+ var indexable = i as IIndexableChannel;
+
+ if (indexable != null)
+ {
+ try
+ {
+ var result = await GetAllItems(indexable, i, new InternalAllChannelMediaQuery
+ {
+ UserId = query.UserId,
+ ContentTypes = query.ContentTypes,
+ ExtraTypes = query.ExtraTypes,
+ TrailerTypes = query.TrailerTypes
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ return new Tuple<IChannel, ChannelItemResult>(i, result);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting all media from {0}", ex, i.Name);
+ }
+ }
+ return new Tuple<IChannel, ChannelItemResult>(i, new ChannelItemResult());
+ });
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ var totalCount = results.Length;
+
+ IEnumerable<Tuple<IChannel, ChannelItemInfo>> items = results
+ .SelectMany(i => i.Item2.Items.Select(m => new Tuple<IChannel, ChannelItemInfo>(i.Item1, m)))
+ .OrderBy(i => i.Item2.Name);
+
+ if (query.StartIndex.HasValue)
+ {
+ items = items.Skip(query.StartIndex.Value);
+ }
+ if (query.Limit.HasValue)
+ {
+ items = items.Take(query.Limit.Value);
+ }
+
+ // Avoid implicitly captured closure
+ var token = cancellationToken;
+ var itemTasks = items.Select(i =>
+ {
+ var channelProvider = i.Item1;
+ var internalChannelId = GetInternalChannelId(channelProvider.Name);
+ return GetChannelItemEntity(i.Item2, channelProvider, internalChannelId, token);
+ });
+
+ var internalItems = await Task.WhenAll(itemTasks).ConfigureAwait(false);
+
+ var returnItemArray = internalItems.ToArray();
+
+ return new QueryResult<BaseItem>
+ {
+ TotalRecordCount = totalCount,
+ Items = returnItemArray
+ };
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetAllMedia(AllChannelMediaQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ var internalResult = await GetAllMediaInternal(query, cancellationToken).ConfigureAwait(false);
+
+ RefreshIfNeeded(internalResult.Items);
+
+ var dtoOptions = new DtoOptions();
+
+ var returnItems = (await _dtoService.GetBaseItemDtos(internalResult.Items, dtoOptions, user).ConfigureAwait(false))
+ .ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnItems,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ private async Task<ChannelItemResult> GetAllItems(IIndexableChannel indexable, IChannel channel, InternalAllChannelMediaQuery query, CancellationToken cancellationToken)
+ {
+ var cacheLength = CacheLength;
+ var folderId = _jsonSerializer.SerializeToString(query).GetMD5().ToString("N");
+ var cachePath = GetChannelDataCachePath(channel, query.UserId, folderId, null, false);
+
+ try
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ return _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ try
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ return _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ var result = await indexable.GetAllMedia(query, cancellationToken).ConfigureAwait(false);
+
+ CacheResponse(result, cachePath);
+
+ return result;
+ }
+ finally
+ {
+ _resourcePool.Release();
+ }
+ }
+
+ public async Task<QueryResult<BaseItem>> GetChannelItemsInternal(ChannelItemQuery query, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ // Get the internal channel entity
+ var channel = GetChannel(query.ChannelId);
+
+ // Find the corresponding channel provider plugin
+ var channelProvider = GetChannelProvider(channel);
+
+ var channelInfo = channelProvider.GetChannelFeatures();
+
+ int? providerStartIndex = null;
+ int? providerLimit = null;
+
+ if (channelInfo.MaxPageSize.HasValue)
+ {
+ providerStartIndex = query.StartIndex;
+
+ if (query.Limit.HasValue && query.Limit.Value > channelInfo.MaxPageSize.Value)
+ {
+ query.Limit = Math.Min(query.Limit.Value, channelInfo.MaxPageSize.Value);
+ }
+ providerLimit = query.Limit;
+
+ // This will cause some providers to fail
+ if (providerLimit == 0)
+ {
+ providerLimit = 1;
+ }
+ }
+
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ ChannelItemSortField? sortField = null;
+ ChannelItemSortField parsedField;
+ if (query.SortBy.Length == 1 &&
+ Enum.TryParse(query.SortBy[0], true, out parsedField))
+ {
+ sortField = parsedField;
+ }
+
+ var sortDescending = query.SortOrder.HasValue && query.SortOrder.Value == SortOrder.Descending;
+
+ var itemsResult = await GetChannelItems(channelProvider,
+ user,
+ query.FolderId,
+ providerStartIndex,
+ providerLimit,
+ sortField,
+ sortDescending,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ var providerTotalRecordCount = providerLimit.HasValue ? itemsResult.TotalRecordCount : null;
+
+ var tasks = itemsResult.Items.Select(i => GetChannelItemEntity(i, channelProvider, channel.Id, cancellationToken));
+
+ var internalItems = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ if (user != null)
+ {
+ internalItems = internalItems.Where(i => i.IsVisible(user)).ToArray();
+
+ if (providerTotalRecordCount.HasValue)
+ {
+ providerTotalRecordCount = providerTotalRecordCount.Value;
+ }
+ }
+
+ return await GetReturnItems(internalItems, providerTotalRecordCount, user, query).ConfigureAwait(false);
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetChannelItems(ChannelItemQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrWhiteSpace(query.UserId)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ var internalResult = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false);
+
+ var dtoOptions = new DtoOptions();
+
+ var returnItems = (await _dtoService.GetBaseItemDtos(internalResult.Items, dtoOptions, user).ConfigureAwait(false))
+ .ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnItems,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ private string GetItemExternalId(BaseItem item)
+ {
+ var externalId = item.ExternalId;
+
+ if (string.IsNullOrWhiteSpace(externalId))
+ {
+ externalId = item.GetProviderId("ProviderExternalId");
+ }
+
+ return externalId;
+ }
+
+ private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1);
+ private async Task<ChannelItemResult> GetChannelItems(IChannel channel,
+ User user,
+ string folderId,
+ int? startIndex,
+ int? limit,
+ ChannelItemSortField? sortField,
+ bool sortDescending,
+ CancellationToken cancellationToken)
+ {
+ var userId = user.Id.ToString("N");
+
+ var cacheLength = CacheLength;
+ var cachePath = GetChannelDataCachePath(channel, userId, folderId, sortField, sortDescending);
+
+ try
+ {
+ if (!startIndex.HasValue && !limit.HasValue)
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath);
+ if (cachedResult != null)
+ {
+ return cachedResult;
+ }
+ }
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ try
+ {
+ if (!startIndex.HasValue && !limit.HasValue)
+ {
+ if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow)
+ {
+ var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath);
+ if (cachedResult != null)
+ {
+ return cachedResult;
+ }
+ }
+ }
+ }
+ catch (FileNotFoundException)
+ {
+
+ }
+ catch (IOException)
+ {
+
+ }
+
+ var query = new InternalChannelItemQuery
+ {
+ UserId = userId,
+ StartIndex = startIndex,
+ Limit = limit,
+ SortBy = sortField,
+ SortDescending = sortDescending
+ };
+
+ if (!string.IsNullOrWhiteSpace(folderId))
+ {
+ var categoryItem = _libraryManager.GetItemById(new Guid(folderId));
+
+ query.FolderId = GetItemExternalId(categoryItem);
+ }
+
+ var result = await channel.GetChannelItems(query, cancellationToken).ConfigureAwait(false);
+
+ if (result == null)
+ {
+ throw new InvalidOperationException("Channel returned a null result from GetChannelItems");
+ }
+
+ if (!startIndex.HasValue && !limit.HasValue)
+ {
+ CacheResponse(result, cachePath);
+ }
+
+ return result;
+ }
+ finally
+ {
+ _resourcePool.Release();
+ }
+ }
+
+ private void CacheResponse(object result, string path)
+ {
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ _jsonSerializer.SerializeToFile(result, path);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error writing to channel cache file: {0}", ex, path);
+ }
+ }
+
+ private string GetChannelDataCachePath(IChannel channel,
+ string userId,
+ string folderId,
+ ChannelItemSortField? sortField,
+ bool sortDescending)
+ {
+ var channelId = GetInternalChannelId(channel.Name).ToString("N");
+
+ var userCacheKey = string.Empty;
+
+ var hasCacheKey = channel as IHasCacheKey;
+ if (hasCacheKey != null)
+ {
+ userCacheKey = hasCacheKey.GetCacheKey(userId) ?? string.Empty;
+ }
+
+ var filename = string.IsNullOrWhiteSpace(folderId) ? "root" : folderId;
+ filename += userCacheKey;
+
+ var version = (channel.DataVersion ?? string.Empty).GetMD5().ToString("N");
+
+ if (sortField.HasValue)
+ {
+ filename += "-sortField-" + sortField.Value;
+ }
+ if (sortDescending)
+ {
+ filename += "-sortDescending";
+ }
+
+ filename = filename.GetMD5().ToString("N");
+
+ return Path.Combine(_config.ApplicationPaths.CachePath,
+ "channels",
+ channelId,
+ version,
+ filename + ".json");
+ }
+
+ private async Task<QueryResult<BaseItem>> GetReturnItems(IEnumerable<BaseItem> items,
+ int? totalCountFromProvider,
+ User user,
+ ChannelItemQuery query)
+ {
+ items = ApplyFilters(items, query.Filters, user);
+
+ items = _libraryManager.Sort(items, user, query.SortBy, query.SortOrder ?? SortOrder.Ascending);
+
+ var all = items.ToList();
+ var totalCount = totalCountFromProvider ?? all.Count;
+
+ if (!totalCountFromProvider.HasValue)
+ {
+ if (query.StartIndex.HasValue)
+ {
+ all = all.Skip(query.StartIndex.Value).ToList();
+ }
+ if (query.Limit.HasValue)
+ {
+ all = all.Take(query.Limit.Value).ToList();
+ }
+ }
+
+ var returnItemArray = all.ToArray();
+ RefreshIfNeeded(returnItemArray);
+
+ return new QueryResult<BaseItem>
+ {
+ Items = returnItemArray,
+ TotalRecordCount = totalCount
+ };
+ }
+
+ private string GetIdToHash(string externalId, string channelName)
+ {
+ // Increment this as needed to force new downloads
+ // Incorporate Name because it's being used to convert channel entity to provider
+ return externalId + (channelName ?? string.Empty) + "16";
+ }
+
+ private T GetItemById<T>(string idString, string channelName, string channnelDataVersion, out bool isNew)
+ where T : BaseItem, new()
+ {
+ var id = GetIdToHash(idString, channelName).GetMBId(typeof(T));
+
+ T item = null;
+
+ try
+ {
+ item = _libraryManager.GetItemById(id) as T;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error retrieving channel item from database", ex);
+ }
+
+ if (item == null || !string.Equals(item.ExternalEtag, channnelDataVersion, StringComparison.Ordinal))
+ {
+ item = new T();
+ isNew = true;
+ }
+ else
+ {
+ isNew = false;
+ }
+
+ item.ExternalEtag = channnelDataVersion;
+ item.Id = id;
+ return item;
+ }
+
+ private async Task<BaseItem> GetChannelItemEntity(ChannelItemInfo info, IChannel channelProvider, Guid internalChannelId, CancellationToken cancellationToken)
+ {
+ BaseItem item;
+ bool isNew;
+ bool forceUpdate = false;
+
+ if (info.Type == ChannelItemType.Folder)
+ {
+ if (info.FolderType == ChannelFolderType.MusicAlbum)
+ {
+ item = GetItemById<MusicAlbum>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.FolderType == ChannelFolderType.MusicArtist)
+ {
+ item = GetItemById<MusicArtist>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.FolderType == ChannelFolderType.PhotoAlbum)
+ {
+ item = GetItemById<PhotoAlbum>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.FolderType == ChannelFolderType.Series)
+ {
+ item = GetItemById<Series>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.FolderType == ChannelFolderType.Season)
+ {
+ item = GetItemById<Season>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else
+ {
+ item = GetItemById<Folder>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ }
+ else if (info.MediaType == ChannelMediaType.Audio)
+ {
+ if (info.ContentType == ChannelMediaContentType.Podcast)
+ {
+ item = GetItemById<AudioPodcast>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else
+ {
+ item = GetItemById<Audio>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ }
+ else
+ {
+ if (info.ContentType == ChannelMediaContentType.Episode)
+ {
+ item = GetItemById<Episode>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.ContentType == ChannelMediaContentType.Movie)
+ {
+ item = GetItemById<Movie>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else if (info.ContentType == ChannelMediaContentType.Trailer || info.ExtraType == ExtraType.Trailer)
+ {
+ item = GetItemById<Trailer>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ else
+ {
+ item = GetItemById<Video>(info.Id, channelProvider.Name, channelProvider.DataVersion, out isNew);
+ }
+ }
+
+ item.RunTimeTicks = info.RunTimeTicks;
+
+ if (isNew)
+ {
+ item.Name = info.Name;
+ item.Genres = info.Genres;
+ item.Studios = info.Studios;
+ item.CommunityRating = info.CommunityRating;
+ item.Overview = info.Overview;
+ item.IndexNumber = info.IndexNumber;
+ item.ParentIndexNumber = info.ParentIndexNumber;
+ item.PremiereDate = info.PremiereDate;
+ item.ProductionYear = info.ProductionYear;
+ item.ProviderIds = info.ProviderIds;
+ item.OfficialRating = info.OfficialRating;
+ item.DateCreated = info.DateCreated ?? DateTime.UtcNow;
+ item.Tags = info.Tags;
+ item.HomePageUrl = info.HomePageUrl;
+ }
+ else if (info.Type == ChannelItemType.Folder && info.FolderType == ChannelFolderType.Container)
+ {
+ // At least update names of container folders
+ if (item.Name != info.Name)
+ {
+ item.Name = info.Name;
+ forceUpdate = true;
+ }
+ }
+
+ var hasArtists = item as IHasArtist;
+ if (hasArtists != null)
+ {
+ hasArtists.Artists = info.Artists;
+ }
+
+ var hasAlbumArtists = item as IHasAlbumArtist;
+ if (hasAlbumArtists != null)
+ {
+ hasAlbumArtists.AlbumArtists = info.AlbumArtists;
+ }
+
+ var trailer = item as Trailer;
+ if (trailer != null)
+ {
+ if (!info.TrailerTypes.SequenceEqual(trailer.TrailerTypes))
+ {
+ forceUpdate = true;
+ }
+ trailer.TrailerTypes = info.TrailerTypes;
+ }
+
+ item.ChannelId = internalChannelId.ToString("N");
+
+ if (item.ParentId != internalChannelId)
+ {
+ forceUpdate = true;
+ }
+ item.ParentId = internalChannelId;
+
+ if (!string.Equals(item.ExternalId, info.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ forceUpdate = true;
+ }
+ item.ExternalId = info.Id;
+
+ var channelAudioItem = item as Audio;
+ if (channelAudioItem != null)
+ {
+ channelAudioItem.ExtraType = info.ExtraType;
+
+ var mediaSource = info.MediaSources.FirstOrDefault();
+ item.Path = mediaSource == null ? null : mediaSource.Path;
+ }
+
+ var channelVideoItem = item as Video;
+ if (channelVideoItem != null)
+ {
+ channelVideoItem.ExtraType = info.ExtraType;
+ channelVideoItem.ChannelMediaSources = info.MediaSources;
+
+ var mediaSource = info.MediaSources.FirstOrDefault();
+ item.Path = mediaSource == null ? null : mediaSource.Path;
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.ImageUrl) && !item.HasImage(ImageType.Primary))
+ {
+ item.SetImagePath(ImageType.Primary, info.ImageUrl);
+ }
+
+ if (item.SourceType != SourceType.Channel)
+ {
+ item.SourceType = SourceType.Channel;
+ forceUpdate = true;
+ }
+
+ if (isNew)
+ {
+ await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false);
+
+ if (info.People != null && info.People.Count > 0)
+ {
+ await _libraryManager.UpdatePeople(item, info.People ?? new List<PersonInfo>()).ConfigureAwait(false);
+ }
+ }
+ else if (forceUpdate)
+ {
+ await item.UpdateToRepository(ItemUpdateType.None, cancellationToken).ConfigureAwait(false);
+ }
+
+ SaveMediaSources(item, info.MediaSources);
+
+ return item;
+ }
+
+ private void RefreshIfNeeded(BaseItem[] programs)
+ {
+ foreach (var program in programs)
+ {
+ RefreshIfNeeded(program);
+ }
+ }
+
+ private void RefreshIfNeeded(BaseItem program)
+ {
+ if (!_refreshedItems.ContainsKey(program.Id))
+ {
+ _refreshedItems.TryAdd(program.Id, true);
+ _providerManager.QueueRefresh(program.Id, new MetadataRefreshOptions(_fileSystem));
+ }
+
+ }
+
+ internal IChannel GetChannelProvider(Channel channel)
+ {
+ if (channel == null)
+ {
+ throw new ArgumentNullException("channel");
+ }
+
+ var result = GetAllChannels()
+ .FirstOrDefault(i => string.Equals(GetInternalChannelId(i.Name).ToString("N"), channel.ChannelId, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Name, channel.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (result == null)
+ {
+ throw new ResourceNotFoundException("No channel provider found for channel " + channel.Name);
+ }
+
+ return result;
+ }
+
+ internal IChannel GetChannelProvider(string internalChannelId)
+ {
+ if (internalChannelId == null)
+ {
+ throw new ArgumentNullException("internalChannelId");
+ }
+
+ var result = GetAllChannels()
+ .FirstOrDefault(i => string.Equals(GetInternalChannelId(i.Name).ToString("N"), internalChannelId, StringComparison.OrdinalIgnoreCase));
+
+ if (result == null)
+ {
+ throw new ResourceNotFoundException("No channel provider found for channel id " + internalChannelId);
+ }
+
+ return result;
+ }
+
+ private IEnumerable<BaseItem> ApplyFilters(IEnumerable<BaseItem> items, IEnumerable<ItemFilter> filters, User user)
+ {
+ foreach (var filter in filters.OrderByDescending(f => (int)f))
+ {
+ items = ApplyFilter(items, filter, user);
+ }
+
+ return items;
+ }
+
+ private IEnumerable<BaseItem> ApplyFilter(IEnumerable<BaseItem> items, ItemFilter filter, User user)
+ {
+ // Avoid implicitly captured closure
+ var currentUser = user;
+
+ switch (filter)
+ {
+ case ItemFilter.IsFavoriteOrLikes:
+ return items.Where(item =>
+ {
+ var userdata = _userDataManager.GetUserData(user, item);
+
+ if (userdata == null)
+ {
+ return false;
+ }
+
+ var likes = userdata.Likes ?? false;
+ var favorite = userdata.IsFavorite;
+
+ return likes || favorite;
+ });
+
+ case ItemFilter.Likes:
+ return items.Where(item =>
+ {
+ var userdata = _userDataManager.GetUserData(user, item);
+
+ return userdata != null && userdata.Likes.HasValue && userdata.Likes.Value;
+ });
+
+ case ItemFilter.Dislikes:
+ return items.Where(item =>
+ {
+ var userdata = _userDataManager.GetUserData(user, item);
+
+ return userdata != null && userdata.Likes.HasValue && !userdata.Likes.Value;
+ });
+
+ case ItemFilter.IsFavorite:
+ return items.Where(item =>
+ {
+ var userdata = _userDataManager.GetUserData(user, item);
+
+ return userdata != null && userdata.IsFavorite;
+ });
+
+ case ItemFilter.IsResumable:
+ return items.Where(item =>
+ {
+ var userdata = _userDataManager.GetUserData(user, item);
+
+ return userdata != null && userdata.PlaybackPositionTicks > 0;
+ });
+
+ case ItemFilter.IsPlayed:
+ return items.Where(item => item.IsPlayed(currentUser));
+
+ case ItemFilter.IsUnplayed:
+ return items.Where(item => item.IsUnplayed(currentUser));
+
+ case ItemFilter.IsFolder:
+ return items.Where(item => item.IsFolder);
+
+ case ItemFilter.IsNotFolder:
+ return items.Where(item => !item.IsFolder);
+ }
+
+ return items;
+ }
+
+ public async Task<BaseItemDto> GetChannelFolder(string userId, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(userId) ? null : _userManager.GetUserById(userId);
+
+ var folder = await GetInternalChannelFolder(cancellationToken).ConfigureAwait(false);
+
+ return _dtoService.GetBaseItemDto(folder, new DtoOptions(), user);
+ }
+
+ public async Task<Folder> GetInternalChannelFolder(CancellationToken cancellationToken)
+ {
+ var name = _localization.GetLocalizedString("ViewTypeChannels");
+
+ return await _libraryManager.GetNamedView(name, "channels", "zz_" + name, cancellationToken).ConfigureAwait(false);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
new file mode 100644
index 000000000..aef06bd1d
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
@@ -0,0 +1,257 @@
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.Channels
+{
+ public class ChannelPostScanTask
+ {
+ private readonly IChannelManager _channelManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+ private readonly ILibraryManager _libraryManager;
+
+ public ChannelPostScanTask(IChannelManager channelManager, IUserManager userManager, ILogger logger, ILibraryManager libraryManager)
+ {
+ _channelManager = channelManager;
+ _userManager = userManager;
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
+
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var users = _userManager.Users
+ .DistinctBy(GetUserDistinctValue)
+ .Select(i => i.Id.ToString("N"))
+ .ToList();
+
+ var numComplete = 0;
+
+ foreach (var user in users)
+ {
+ double percentPerUser = 1;
+ percentPerUser /= users.Count;
+ var startingPercent = numComplete * percentPerUser * 100;
+
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p => progress.Report(startingPercent + percentPerUser * p));
+
+ await DownloadContent(user, cancellationToken, innerProgress).ConfigureAwait(false);
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= users.Count;
+ progress.Report(percent * 100);
+ }
+
+ await CleanDatabase(cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ public static string GetUserDistinctValue(User user)
+ {
+ var channels = user.Policy.EnabledChannels
+ .OrderBy(i => i)
+ .ToList();
+
+ return string.Join("|", channels.ToArray());
+ }
+
+ private async Task DownloadContent(string user, CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var channels = await _channelManager.GetChannelsInternal(new ChannelQuery
+ {
+ UserId = user
+
+ }, cancellationToken);
+
+ var numComplete = 0;
+ var numItems = channels.Items.Length;
+
+ foreach (var channel in channels.Items)
+ {
+ var channelId = channel.Id.ToString("N");
+
+ var features = _channelManager.GetChannelFeatures(channelId);
+
+ const int currentRefreshLevel = 1;
+ var maxRefreshLevel = features.AutoRefreshLevels ?? 0;
+ maxRefreshLevel = Math.Max(maxRefreshLevel, 2);
+
+ if (maxRefreshLevel > 0)
+ {
+ var innerProgress = new ActionableProgress<double>();
+
+ var startingNumberComplete = numComplete;
+ innerProgress.RegisterAction(p =>
+ {
+ double innerPercent = startingNumberComplete;
+ innerPercent += p / 100;
+ innerPercent /= numItems;
+ progress.Report(innerPercent * 100);
+ });
+
+ try
+ {
+ await GetAllItems(user, channelId, null, currentRefreshLevel, maxRefreshLevel, innerProgress, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channel content", ex);
+ }
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= numItems;
+ progress.Report(percent * 100);
+ }
+
+ progress.Report(100);
+ }
+
+ private async Task CleanDatabase(CancellationToken cancellationToken)
+ {
+ var installedChannelIds = ((ChannelManager)_channelManager).GetInstalledChannelIds();
+
+ var databaseIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Channel).Name }
+ });
+
+ var invalidIds = databaseIds
+ .Except(installedChannelIds)
+ .ToList();
+
+ foreach (var id in invalidIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await CleanChannel(id, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task CleanChannel(Guid id, CancellationToken cancellationToken)
+ {
+ _logger.Info("Cleaning channel {0} from database", id);
+
+ // Delete all channel items
+ var allIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ ChannelIds = new[] { id.ToString("N") }
+ });
+
+ foreach (var deleteId in allIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await DeleteItem(deleteId).ConfigureAwait(false);
+ }
+
+ // Finally, delete the channel itself
+ await DeleteItem(id).ConfigureAwait(false);
+ }
+
+ private Task DeleteItem(Guid id)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ return Task.FromResult(true);
+ }
+
+ return _libraryManager.DeleteItem(item, new DeleteOptions
+ {
+ DeleteFileLocation = false
+ });
+ }
+
+ private async Task GetAllItems(string user, string channelId, string folderId, int currentRefreshLevel, int maxRefreshLevel, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var folderItems = new List<string>();
+
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p => progress.Report(p / 2));
+
+ var result = await _channelManager.GetChannelItemsInternal(new ChannelItemQuery
+ {
+ ChannelId = channelId,
+ UserId = user,
+ FolderId = folderId
+
+ }, innerProgress, cancellationToken);
+
+ folderItems.AddRange(result.Items.Where(i => i.IsFolder).Select(i => i.Id.ToString("N")));
+
+ var totalRetrieved = result.Items.Length;
+ var totalCount = result.TotalRecordCount;
+
+ while (totalRetrieved < totalCount)
+ {
+ result = await _channelManager.GetChannelItemsInternal(new ChannelItemQuery
+ {
+ ChannelId = channelId,
+ UserId = user,
+ StartIndex = totalRetrieved,
+ FolderId = folderId
+
+ }, new Progress<double>(), cancellationToken);
+
+ folderItems.AddRange(result.Items.Where(i => i.IsFolder).Select(i => i.Id.ToString("N")));
+
+ totalRetrieved += result.Items.Length;
+ totalCount = result.TotalRecordCount;
+ }
+
+ progress.Report(50);
+
+ if (currentRefreshLevel < maxRefreshLevel)
+ {
+ var numComplete = 0;
+ var numItems = folderItems.Count;
+
+ foreach (var folder in folderItems)
+ {
+ try
+ {
+ innerProgress = new ActionableProgress<double>();
+
+ var startingNumberComplete = numComplete;
+ innerProgress.RegisterAction(p =>
+ {
+ double innerPercent = startingNumberComplete;
+ innerPercent += p / 100;
+ innerPercent /= numItems;
+ progress.Report(innerPercent * 50 + 50);
+ });
+
+ await GetAllItems(user, channelId, folder, currentRefreshLevel + 1, maxRefreshLevel, innerProgress, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channel content", ex);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= numItems;
+ progress.Report(percent * 50 + 50);
+ }
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs
new file mode 100644
index 000000000..d5ec86445
--- /dev/null
+++ b/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs
@@ -0,0 +1,78 @@
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Channels
+{
+ class RefreshChannelsScheduledTask : IScheduledTask
+ {
+ private readonly IChannelManager _channelManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+ private readonly ILibraryManager _libraryManager;
+
+ public RefreshChannelsScheduledTask(IChannelManager channelManager, IUserManager userManager, ILogger logger, ILibraryManager libraryManager)
+ {
+ _channelManager = channelManager;
+ _userManager = userManager;
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
+
+ public string Name
+ {
+ get { return "Refresh Channels"; }
+ }
+
+ public string Description
+ {
+ get { return "Refreshes internet channel information."; }
+ }
+
+ public string Category
+ {
+ get { return "Internet Channels"; }
+ }
+
+ public async Task Execute(System.Threading.CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var manager = (ChannelManager)_channelManager;
+
+ await manager.RefreshChannels(new Progress<double>(), cancellationToken).ConfigureAwait(false);
+
+ await new ChannelPostScanTask(_channelManager, _userManager, _logger, _libraryManager).Run(progress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks}
+ };
+ }
+
+ public string Key
+ {
+ get { return "RefreshInternetChannels"; }
+ }
+
+ public bool IsHidden
+ {
+ get { return false; }
+ }
+
+ public bool IsEnabled
+ {
+ get { return true; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs
new file mode 100644
index 000000000..b82d4e44e
--- /dev/null
+++ b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs
@@ -0,0 +1,84 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Images;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.Collections
+{
+ public class CollectionImageProvider : BaseDynamicImageProvider<BoxSet>
+ {
+ public CollectionImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ }
+
+ protected override bool Supports(IHasImages item)
+ {
+ // Right now this is the only way to prevent this image from getting created ahead of internet image providers
+ if (!item.IsLocked)
+ {
+ return false;
+ }
+
+ return base.Supports(item);
+ }
+
+ protected override Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var playlist = (BoxSet)item;
+
+ var items = playlist.Children.Concat(playlist.GetLinkedChildren())
+ .Select(i =>
+ {
+ var subItem = i;
+
+ var episode = subItem as Episode;
+
+ if (episode != null)
+ {
+ var series = episode.Series;
+ if (series != null && series.HasImage(ImageType.Primary))
+ {
+ return series;
+ }
+ }
+
+ if (subItem.HasImage(ImageType.Primary))
+ {
+ return subItem;
+ }
+
+ var parent = subItem.GetParent();
+
+ if (parent != null && parent.HasImage(ImageType.Primary))
+ {
+ if (parent is MusicAlbum)
+ {
+ return parent;
+ }
+ }
+
+ return null;
+ })
+ .Where(i => i != null)
+ .DistinctBy(i => i.Id)
+ .ToList();
+
+ return Task.FromResult(GetFinalItems(items, 2));
+ }
+
+ protected override Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ {
+ return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
new file mode 100644
index 000000000..d0bd76c35
--- /dev/null
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -0,0 +1,296 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Collections
+{
+ public class CollectionManager : ICollectionManager
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryMonitor _iLibraryMonitor;
+ private readonly ILogger _logger;
+ private readonly IProviderManager _providerManager;
+
+ public event EventHandler<CollectionCreatedEventArgs> CollectionCreated;
+ public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection;
+ public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection;
+
+ public CollectionManager(ILibraryManager libraryManager, IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILogger logger, IProviderManager providerManager)
+ {
+ _libraryManager = libraryManager;
+ _fileSystem = fileSystem;
+ _iLibraryMonitor = iLibraryMonitor;
+ _logger = logger;
+ _providerManager = providerManager;
+ }
+
+ public Folder GetCollectionsFolder(string userId)
+ {
+ return _libraryManager.RootFolder.Children.OfType<ManualCollectionsFolder>()
+ .FirstOrDefault() ?? _libraryManager.GetUserRootFolder().Children.OfType<ManualCollectionsFolder>()
+ .FirstOrDefault();
+ }
+
+ public IEnumerable<BoxSet> GetCollections(User user)
+ {
+ var folder = GetCollectionsFolder(user.Id.ToString("N"));
+ return folder == null ?
+ new List<BoxSet>() :
+ folder.GetChildren(user, true).OfType<BoxSet>();
+ }
+
+ public async Task<BoxSet> CreateCollection(CollectionCreationOptions options)
+ {
+ var name = options.Name;
+
+ // Need to use the [boxset] suffix
+ // If internet metadata is not found, or if xml saving is off there will be no collection.xml
+ // This could cause it to get re-resolved as a plain folder
+ var folderName = _fileSystem.GetValidFilename(name) + " [boxset]";
+
+ var parentFolder = GetParentFolder(options.ParentId);
+
+ if (parentFolder == null)
+ {
+ throw new ArgumentException();
+ }
+
+ var path = Path.Combine(parentFolder.Path, folderName);
+
+ _iLibraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ try
+ {
+ _fileSystem.CreateDirectory(path);
+
+ var collection = new BoxSet
+ {
+ Name = name,
+ Path = path,
+ IsLocked = options.IsLocked,
+ ProviderIds = options.ProviderIds,
+ Shares = options.UserIds.Select(i => new Share
+ {
+ UserId = i.ToString("N"),
+ CanEdit = true
+
+ }).ToList()
+ };
+
+ await parentFolder.AddChild(collection, CancellationToken.None).ConfigureAwait(false);
+
+ if (options.ItemIdList.Count > 0)
+ {
+ await AddToCollection(collection.Id, options.ItemIdList, false, new MetadataRefreshOptions(_fileSystem)
+ {
+ // The initial adding of items is going to create a local metadata file
+ // This will cause internet metadata to be skipped as a result
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh
+ });
+ }
+ else
+ {
+ _providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(_fileSystem));
+ }
+
+ EventHelper.FireEventIfNotNull(CollectionCreated, this, new CollectionCreatedEventArgs
+ {
+ Collection = collection,
+ Options = options
+
+ }, _logger);
+
+ return collection;
+ }
+ finally
+ {
+ // Refresh handled internally
+ _iLibraryMonitor.ReportFileSystemChangeComplete(path, false);
+ }
+ }
+
+ private Folder GetParentFolder(Guid? parentId)
+ {
+ if (parentId.HasValue)
+ {
+ if (parentId.Value == Guid.Empty)
+ {
+ throw new ArgumentNullException("parentId");
+ }
+
+ var folder = _libraryManager.GetItemById(parentId.Value) as Folder;
+
+ // Find an actual physical folder
+ if (folder is CollectionFolder)
+ {
+ var child = _libraryManager.RootFolder.Children.OfType<Folder>()
+ .FirstOrDefault(i => folder.PhysicalLocations.Contains(i.Path, StringComparer.OrdinalIgnoreCase));
+
+ if (child != null)
+ {
+ return child;
+ }
+ }
+ }
+
+ return GetCollectionsFolder(string.Empty);
+ }
+
+ public Task AddToCollection(Guid collectionId, IEnumerable<Guid> ids)
+ {
+ return AddToCollection(collectionId, ids, true, new MetadataRefreshOptions(_fileSystem));
+ }
+
+ private async Task AddToCollection(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions)
+ {
+ var collection = _libraryManager.GetItemById(collectionId) as BoxSet;
+
+ if (collection == null)
+ {
+ throw new ArgumentException("No collection exists with the supplied Id");
+ }
+
+ var list = new List<LinkedChild>();
+ var itemList = new List<BaseItem>();
+ var currentLinkedChildren = collection.GetLinkedChildren().ToList();
+
+ foreach (var itemId in ids)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item == null)
+ {
+ throw new ArgumentException("No item exists with the supplied Id");
+ }
+
+ itemList.Add(item);
+
+ if (currentLinkedChildren.All(i => i.Id != itemId))
+ {
+ list.Add(LinkedChild.Create(item));
+ }
+ }
+
+ if (list.Count > 0)
+ {
+ collection.LinkedChildren.AddRange(list);
+
+ collection.UpdateRatingToContent();
+
+ await collection.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ _providerManager.QueueRefresh(collection.Id, refreshOptions);
+
+ if (fireEvent)
+ {
+ EventHelper.FireEventIfNotNull(ItemsAddedToCollection, this, new CollectionModifiedEventArgs
+ {
+ Collection = collection,
+ ItemsChanged = itemList
+
+ }, _logger);
+ }
+ }
+ }
+
+ public async Task RemoveFromCollection(Guid collectionId, IEnumerable<Guid> itemIds)
+ {
+ var collection = _libraryManager.GetItemById(collectionId) as BoxSet;
+
+ if (collection == null)
+ {
+ throw new ArgumentException("No collection exists with the supplied Id");
+ }
+
+ var list = new List<LinkedChild>();
+ var itemList = new List<BaseItem>();
+
+ foreach (var itemId in itemIds)
+ {
+ var childItem = _libraryManager.GetItemById(itemId);
+
+ var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value == itemId) || (childItem != null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase)));
+
+ if (child == null)
+ {
+ throw new ArgumentException("No collection title exists with the supplied Id");
+ }
+
+ list.Add(child);
+
+ if (childItem != null)
+ {
+ itemList.Add(childItem);
+ }
+ }
+
+ foreach (var child in list)
+ {
+ collection.LinkedChildren.Remove(child);
+ }
+
+ collection.UpdateRatingToContent();
+
+ await collection.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ _providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(_fileSystem));
+
+ EventHelper.FireEventIfNotNull(ItemsRemovedFromCollection, this, new CollectionModifiedEventArgs
+ {
+ Collection = collection,
+ ItemsChanged = itemList
+
+ }, _logger);
+ }
+
+ public IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user)
+ {
+ var results = new Dictionary<Guid, BaseItem>();
+
+ var allBoxsets = GetCollections(user).ToList();
+
+ foreach (var item in items)
+ {
+ var grouping = item as ISupportsBoxSetGrouping;
+
+ if (grouping == null)
+ {
+ results[item.Id] = item;
+ }
+ else
+ {
+ var itemId = item.Id;
+
+ var currentBoxSets = allBoxsets
+ .Where(i => i.GetLinkedChildren().Any(j => j.Id == itemId))
+ .ToList();
+
+ if (currentBoxSets.Count > 0)
+ {
+ foreach (var boxset in currentBoxSets)
+ {
+ results[boxset.Id] = boxset;
+ }
+ }
+ else
+ {
+ results[item.Id] = item;
+ }
+ }
+ }
+
+ return results.Values;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Collections/CollectionsDynamicFolder.cs b/Emby.Server.Implementations/Collections/CollectionsDynamicFolder.cs
new file mode 100644
index 000000000..4ff33e645
--- /dev/null
+++ b/Emby.Server.Implementations/Collections/CollectionsDynamicFolder.cs
@@ -0,0 +1,34 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using System.IO;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.IO;
+
+namespace Emby.Server.Implementations.Collections
+{
+ public class CollectionsDynamicFolder : IVirtualFolderCreator
+ {
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+
+ public CollectionsDynamicFolder(IApplicationPaths appPaths, IFileSystem fileSystem)
+ {
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ }
+
+ public BasePluginFolder GetFolder()
+ {
+ var path = Path.Combine(_appPaths.DataPath, "collections");
+
+ _fileSystem.CreateDirectory(path);
+
+ return new ManualCollectionsFolder
+ {
+ Path = path
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/ConnectData.cs b/Emby.Server.Implementations/Connect/ConnectData.cs
new file mode 100644
index 000000000..41b89ce52
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectData.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectData
+ {
+ /// <summary>
+ /// Gets or sets the server identifier.
+ /// </summary>
+ /// <value>The server identifier.</value>
+ public string ServerId { get; set; }
+ /// <summary>
+ /// Gets or sets the access key.
+ /// </summary>
+ /// <value>The access key.</value>
+ public string AccessKey { get; set; }
+
+ /// <summary>
+ /// Gets or sets the authorizations.
+ /// </summary>
+ /// <value>The authorizations.</value>
+ public List<ConnectAuthorizationInternal> PendingAuthorizations { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last authorizations refresh.
+ /// </summary>
+ /// <value>The last authorizations refresh.</value>
+ public DateTime LastAuthorizationsRefresh { get; set; }
+
+ public ConnectData()
+ {
+ PendingAuthorizations = new List<ConnectAuthorizationInternal>();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs b/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs
new file mode 100644
index 000000000..170ef07f3
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectEntryPoint.cs
@@ -0,0 +1,199 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectEntryPoint : IServerEntryPoint
+ {
+ private ITimer _timer;
+ private readonly IHttpClient _httpClient;
+ private readonly IApplicationPaths _appPaths;
+ private readonly ILogger _logger;
+ private readonly IConnectManager _connectManager;
+
+ private readonly INetworkManager _networkManager;
+ private readonly IApplicationHost _appHost;
+ private readonly IFileSystem _fileSystem;
+ private readonly ITimerFactory _timerFactory;
+
+ public ConnectEntryPoint(IHttpClient httpClient, IApplicationPaths appPaths, ILogger logger, INetworkManager networkManager, IConnectManager connectManager, IApplicationHost appHost, IFileSystem fileSystem, ITimerFactory timerFactory)
+ {
+ _httpClient = httpClient;
+ _appPaths = appPaths;
+ _logger = logger;
+ _networkManager = networkManager;
+ _connectManager = connectManager;
+ _appHost = appHost;
+ _fileSystem = fileSystem;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ LoadCachedAddress();
+
+ _timer = _timerFactory.Create(TimerCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromHours(1));
+ ((ConnectManager)_connectManager).Start();
+ }
+
+ private readonly string[] _ipLookups =
+ {
+ "http://bot.whatismyipaddress.com",
+ "https://connect.emby.media/service/ip"
+ };
+
+ private async void TimerCallback(object state)
+ {
+ IpAddressInfo validIpAddress = null;
+
+ foreach (var ipLookupUrl in _ipLookups)
+ {
+ try
+ {
+ validIpAddress = await GetIpAddress(ipLookupUrl).ConfigureAwait(false);
+
+ // Try to find the ipv4 address, if present
+ if (validIpAddress.AddressFamily != IpAddressFamily.InterNetworkV6)
+ {
+ break;
+ }
+ }
+ catch (HttpException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting connection info", ex);
+ }
+ }
+
+ // If this produced an ipv6 address, try again
+ if (validIpAddress != null && validIpAddress.AddressFamily == IpAddressFamily.InterNetworkV6)
+ {
+ foreach (var ipLookupUrl in _ipLookups)
+ {
+ try
+ {
+ var newAddress = await GetIpAddress(ipLookupUrl, true).ConfigureAwait(false);
+
+ // Try to find the ipv4 address, if present
+ if (newAddress.AddressFamily != IpAddressFamily.InterNetworkV6)
+ {
+ validIpAddress = newAddress;
+ break;
+ }
+ }
+ catch (HttpException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting connection info", ex);
+ }
+ }
+ }
+
+ if (validIpAddress != null)
+ {
+ ((ConnectManager)_connectManager).OnWanAddressResolved(validIpAddress);
+ CacheAddress(validIpAddress);
+ }
+ }
+
+ private async Task<IpAddressInfo> GetIpAddress(string lookupUrl, bool preferIpv4 = false)
+ {
+ // Sometimes whatismyipaddress might fail, but it won't do us any good having users raise alarms over it.
+ var logErrors = false;
+
+#if DEBUG
+ logErrors = true;
+#endif
+ using (var stream = await _httpClient.Get(new HttpRequestOptions
+ {
+ Url = lookupUrl,
+ UserAgent = "Emby/" + _appHost.ApplicationVersion,
+ LogErrors = logErrors,
+
+ // Seeing block length errors with our server
+ EnableHttpCompression = false,
+ PreferIpv4 = preferIpv4,
+ BufferContent = false
+
+ }).ConfigureAwait(false))
+ {
+ using (var reader = new StreamReader(stream))
+ {
+ var addressString = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ return _networkManager.ParseIpAddress(addressString);
+ }
+ }
+ }
+
+ private string CacheFilePath
+ {
+ get { return Path.Combine(_appPaths.DataPath, "wan.txt"); }
+ }
+
+ private void CacheAddress(IpAddressInfo address)
+ {
+ var path = CacheFilePath;
+
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+ _fileSystem.WriteAllText(path, address.ToString(), Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving data", ex);
+ }
+ }
+
+ private void LoadCachedAddress()
+ {
+ var path = CacheFilePath;
+
+ _logger.Info("Loading data from {0}", path);
+
+ try
+ {
+ var endpoint = _fileSystem.ReadAllText(path, Encoding.UTF8);
+ IpAddressInfo ipAddress;
+
+ if (_networkManager.TryParseIpAddress(endpoint, out ipAddress))
+ {
+ ((ConnectManager)_connectManager).OnWanAddressResolved(ipAddress);
+ }
+ }
+ catch (IOException)
+ {
+ // File isn't there. no biggie
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error loading data", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/ConnectManager.cs b/Emby.Server.Implementations/Connect/ConnectManager.cs
new file mode 100644
index 000000000..b7faaa901
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/ConnectManager.cs
@@ -0,0 +1,1188 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Model.Connect;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Extensions;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ConnectManager : IConnectManager
+ {
+ private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1);
+
+ private readonly ILogger _logger;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IJsonSerializer _json;
+ private readonly IEncryptionManager _encryption;
+ private readonly IHttpClient _httpClient;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IServerConfigurationManager _config;
+ private readonly IUserManager _userManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ISecurityManager _securityManager;
+ private readonly IFileSystem _fileSystem;
+
+ private ConnectData _data = new ConnectData();
+
+ public string ConnectServerId
+ {
+ get { return _data.ServerId; }
+ }
+ public string ConnectAccessKey
+ {
+ get { return _data.AccessKey; }
+ }
+
+ private IpAddressInfo DiscoveredWanIpAddress { get; set; }
+
+ public string WanIpAddress
+ {
+ get
+ {
+ var address = _config.Configuration.WanDdns;
+
+ if (!string.IsNullOrWhiteSpace(address))
+ {
+ Uri newUri;
+
+ if (Uri.TryCreate(address, UriKind.Absolute, out newUri))
+ {
+ address = newUri.Host;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(address) && DiscoveredWanIpAddress != null)
+ {
+ if (DiscoveredWanIpAddress.AddressFamily == IpAddressFamily.InterNetworkV6)
+ {
+ address = "[" + DiscoveredWanIpAddress + "]";
+ }
+ else
+ {
+ address = DiscoveredWanIpAddress.ToString();
+ }
+ }
+
+ return address;
+ }
+ }
+
+ public string WanApiAddress
+ {
+ get
+ {
+ var ip = WanIpAddress;
+
+ if (!string.IsNullOrEmpty(ip))
+ {
+ if (!ip.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !ip.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ ip = (_appHost.EnableHttps ? "https://" : "http://") + ip;
+ }
+
+ ip += ":";
+ ip += _appHost.EnableHttps ? _config.Configuration.PublicHttpsPort.ToString(CultureInfo.InvariantCulture) : _config.Configuration.PublicPort.ToString(CultureInfo.InvariantCulture);
+
+ return ip;
+ }
+
+ return null;
+ }
+ }
+
+ private string XApplicationValue
+ {
+ get { return _appHost.Name + "/" + _appHost.ApplicationVersion; }
+ }
+
+ public ConnectManager(ILogger logger,
+ IApplicationPaths appPaths,
+ IJsonSerializer json,
+ IEncryptionManager encryption,
+ IHttpClient httpClient,
+ IServerApplicationHost appHost,
+ IServerConfigurationManager config, IUserManager userManager, IProviderManager providerManager, ISecurityManager securityManager, IFileSystem fileSystem)
+ {
+ _logger = logger;
+ _appPaths = appPaths;
+ _json = json;
+ _encryption = encryption;
+ _httpClient = httpClient;
+ _appHost = appHost;
+ _config = config;
+ _userManager = userManager;
+ _providerManager = providerManager;
+ _securityManager = securityManager;
+ _fileSystem = fileSystem;
+
+ LoadCachedData();
+ }
+
+ internal void Start()
+ {
+ _config.ConfigurationUpdated += _config_ConfigurationUpdated;
+ }
+
+ internal void OnWanAddressResolved(IpAddressInfo address)
+ {
+ DiscoveredWanIpAddress = address;
+
+ var task = UpdateConnectInfo();
+ }
+
+ private async Task UpdateConnectInfo()
+ {
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ await UpdateConnectInfoInternal().ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task UpdateConnectInfoInternal()
+ {
+ var wanApiAddress = WanApiAddress;
+
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ _logger.Warn("Cannot update Emby Connect information without a WanApiAddress");
+ return;
+ }
+
+ try
+ {
+ var localAddress = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+
+ var hasExistingRecord = !string.IsNullOrWhiteSpace(ConnectServerId) &&
+ !string.IsNullOrWhiteSpace(ConnectAccessKey);
+
+ var createNewRegistration = !hasExistingRecord;
+
+ if (hasExistingRecord)
+ {
+ try
+ {
+ await UpdateServerRegistration(wanApiAddress, localAddress).ConfigureAwait(false);
+ }
+ catch (HttpException ex)
+ {
+ if (!ex.StatusCode.HasValue || !new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized }.Contains(ex.StatusCode.Value))
+ {
+ throw;
+ }
+
+ createNewRegistration = true;
+ }
+ }
+
+ if (createNewRegistration)
+ {
+ await CreateServerRegistration(wanApiAddress, localAddress).ConfigureAwait(false);
+ }
+
+ _lastReportedIdentifier = GetConnectReportingIdentifier(localAddress, wanApiAddress);
+
+ await RefreshAuthorizationsInternal(true, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error registering with Connect", ex);
+ }
+ }
+
+ private string _lastReportedIdentifier;
+ private async Task<string> GetConnectReportingIdentifier()
+ {
+ var url = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+ return GetConnectReportingIdentifier(url, WanApiAddress);
+ }
+ private string GetConnectReportingIdentifier(string localAddress, string remoteAddress)
+ {
+ return (remoteAddress ?? string.Empty) + (localAddress ?? string.Empty);
+ }
+
+ async void _config_ConfigurationUpdated(object sender, EventArgs e)
+ {
+ // If info hasn't changed, don't report anything
+ var connectIdentifier = await GetConnectReportingIdentifier().ConfigureAwait(false);
+ if (string.Equals(_lastReportedIdentifier, connectIdentifier, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ private async Task CreateServerRegistration(string wanApiAddress, string localAddress)
+ {
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ throw new ArgumentNullException("wanApiAddress");
+ }
+
+ var url = "Servers";
+ url = GetConnectUrl(url);
+
+ var postData = new Dictionary<string, string>
+ {
+ {"name", _appHost.FriendlyName},
+ {"url", wanApiAddress},
+ {"systemId", _appHost.SystemId}
+ };
+
+ if (!string.IsNullOrWhiteSpace(localAddress))
+ {
+ postData["localAddress"] = localAddress;
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ options.SetPostData(postData);
+ SetApplicationHeader(options);
+
+ using (var response = await _httpClient.Post(options).ConfigureAwait(false))
+ {
+ var data = _json.DeserializeFromStream<ServerRegistrationResponse>(response.Content);
+
+ _data.ServerId = data.Id;
+ _data.AccessKey = data.AccessKey;
+
+ CacheData();
+ }
+ }
+
+ private async Task UpdateServerRegistration(string wanApiAddress, string localAddress)
+ {
+ if (string.IsNullOrWhiteSpace(wanApiAddress))
+ {
+ throw new ArgumentNullException("wanApiAddress");
+ }
+
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = "Servers";
+ url = GetConnectUrl(url);
+ url += "?id=" + ConnectServerId;
+
+ var postData = new Dictionary<string, string>
+ {
+ {"name", _appHost.FriendlyName},
+ {"url", wanApiAddress},
+ {"systemId", _appHost.SystemId}
+ };
+
+ if (!string.IsNullOrWhiteSpace(localAddress))
+ {
+ postData["localAddress"] = localAddress;
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ }
+ }
+
+ private readonly object _dataFileLock = new object();
+ private string CacheFilePath
+ {
+ get { return Path.Combine(_appPaths.DataPath, "connect.txt"); }
+ }
+
+ private void CacheData()
+ {
+ var path = CacheFilePath;
+
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ var json = _json.SerializeToString(_data);
+
+ var encrypted = _encryption.EncryptString(json);
+
+ lock (_dataFileLock)
+ {
+ _fileSystem.WriteAllText(path, encrypted, Encoding.UTF8);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving data", ex);
+ }
+ }
+
+ private void LoadCachedData()
+ {
+ var path = CacheFilePath;
+
+ _logger.Info("Loading data from {0}", path);
+
+ try
+ {
+ lock (_dataFileLock)
+ {
+ var encrypted = _fileSystem.ReadAllText(path, Encoding.UTF8);
+
+ var json = _encryption.DecryptString(encrypted);
+
+ _data = _json.DeserializeFromString<ConnectData>(json);
+ }
+ }
+ catch (IOException)
+ {
+ // File isn't there. no biggie
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error loading data", ex);
+ }
+ }
+
+ private User GetUser(string id)
+ {
+ var user = _userManager.GetUserById(id);
+
+ if (user == null)
+ {
+ throw new ArgumentException("User not found.");
+ }
+
+ return user;
+ }
+
+ private string GetConnectUrl(string handler)
+ {
+ return "https://connect.emby.media/service/" + handler;
+ }
+
+ public async Task<UserLinkResult> LinkUser(string userId, string connectUsername)
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (string.IsNullOrWhiteSpace(connectUsername))
+ {
+ throw new ArgumentNullException("connectUsername");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ return await LinkUserInternal(userId, connectUsername).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task<UserLinkResult> LinkUserInternal(string userId, string connectUsername)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var connectUser = await GetConnectUser(new ConnectUserQuery
+ {
+ NameOrEmail = connectUsername
+
+ }, CancellationToken.None).ConfigureAwait(false);
+
+ if (!connectUser.IsActive)
+ {
+ throw new ArgumentException("The Emby account has been disabled.");
+ }
+
+ var existingUser = _userManager.Users.FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUser.Id) && !string.IsNullOrWhiteSpace(i.ConnectAccessKey));
+ if (existingUser != null)
+ {
+ throw new InvalidOperationException("This connect user is already linked to local user " + existingUser.Name);
+ }
+
+ var user = GetUser(userId);
+
+ if (!string.IsNullOrWhiteSpace(user.ConnectUserId))
+ {
+ await RemoveConnect(user, user.ConnectUserId).ConfigureAwait(false);
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var accessToken = Guid.NewGuid().ToString("N");
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUser.Id},
+ {"userType", "Linked"},
+ {"accessToken", accessToken}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ var result = new UserLinkResult();
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ var response = _json.DeserializeFromStream<ServerUserAuthorizationResponse>(stream);
+
+ result.IsPending = string.Equals(response.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase);
+ }
+
+ user.ConnectAccessKey = accessToken;
+ user.ConnectUserName = connectUser.Name;
+ user.ConnectUserId = connectUser.Id;
+ user.ConnectLinkType = UserLinkType.LinkedUser;
+
+ await user.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ await _userManager.UpdateConfiguration(user.Id.ToString("N"), user.Configuration);
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ return result;
+ }
+
+ public async Task<UserLinkResult> InviteUser(ConnectAuthorizationRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ await UpdateConnectInfo().ConfigureAwait(false);
+ }
+
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ return await InviteUserInternal(request).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task<UserLinkResult> InviteUserInternal(ConnectAuthorizationRequest request)
+ {
+ var connectUsername = request.ConnectUserName;
+ var sendingUserId = request.SendingUserId;
+
+ if (string.IsNullOrWhiteSpace(connectUsername))
+ {
+ throw new ArgumentNullException("connectUsername");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var sendingUser = GetUser(sendingUserId);
+ var requesterUserName = sendingUser.ConnectUserName;
+
+ if (string.IsNullOrWhiteSpace(requesterUserName))
+ {
+ throw new ArgumentException("A Connect account is required in order to send invitations.");
+ }
+
+ string connectUserId = null;
+ var result = new UserLinkResult();
+
+ try
+ {
+ var connectUser = await GetConnectUser(new ConnectUserQuery
+ {
+ NameOrEmail = connectUsername
+
+ }, CancellationToken.None).ConfigureAwait(false);
+
+ if (!connectUser.IsActive)
+ {
+ throw new ArgumentException("The Emby account is not active. Please ensure the account has been activated by following the instructions within the email confirmation.");
+ }
+
+ connectUserId = connectUser.Id;
+ result.GuestDisplayName = connectUser.Name;
+ }
+ catch (HttpException ex)
+ {
+ if (!ex.StatusCode.HasValue)
+ {
+ throw;
+ }
+
+ // If they entered a username, then whatever the error is just throw it, for example, user not found
+ if (!Validator.EmailIsValid(connectUsername))
+ {
+ if (ex.StatusCode.Value == HttpStatusCode.NotFound)
+ {
+ throw new ResourceNotFoundException();
+ }
+ throw;
+ }
+
+ if (ex.StatusCode.Value != HttpStatusCode.NotFound)
+ {
+ throw;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(connectUserId))
+ {
+ return await SendNewUserInvitation(requesterUserName, connectUsername).ConfigureAwait(false);
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var accessToken = Guid.NewGuid().ToString("N");
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUserId},
+ {"userType", "Guest"},
+ {"accessToken", accessToken},
+ {"requesterUserName", requesterUserName}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ var response = _json.DeserializeFromStream<ServerUserAuthorizationResponse>(stream);
+
+ result.IsPending = string.Equals(response.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase);
+
+ _data.PendingAuthorizations.Add(new ConnectAuthorizationInternal
+ {
+ ConnectUserId = response.UserId,
+ Id = response.Id,
+ ImageUrl = response.UserImageUrl,
+ UserName = response.UserName,
+ EnabledLibraries = request.EnabledLibraries,
+ EnabledChannels = request.EnabledChannels,
+ EnableLiveTv = request.EnableLiveTv,
+ AccessToken = accessToken
+ });
+
+ CacheData();
+ }
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ return result;
+ }
+
+ private async Task<UserLinkResult> SendNewUserInvitation(string fromName, string email)
+ {
+ var url = GetConnectUrl("users/invite");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var postData = new Dictionary<string, string>
+ {
+ {"email", email},
+ {"requesterUserName", fromName}
+ };
+
+ options.SetPostData(postData);
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ }
+
+ return new UserLinkResult
+ {
+ IsNewUserInvitation = true,
+ GuestDisplayName = email
+ };
+ }
+
+ public Task RemoveConnect(string userId)
+ {
+ var user = GetUser(userId);
+
+ return RemoveConnect(user, user.ConnectUserId);
+ }
+
+ private async Task RemoveConnect(User user, string connectUserId)
+ {
+ if (!string.IsNullOrWhiteSpace(connectUserId))
+ {
+ await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false);
+ }
+
+ user.ConnectAccessKey = null;
+ user.ConnectUserName = null;
+ user.ConnectUserId = null;
+ user.ConnectLinkType = null;
+
+ await user.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task<ConnectUser> GetConnectUser(ConnectUserQuery query, CancellationToken cancellationToken)
+ {
+ var url = GetConnectUrl("user");
+
+ if (!string.IsNullOrWhiteSpace(query.Id))
+ {
+ url = url + "?id=" + WebUtility.UrlEncode(query.Id);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.NameOrEmail))
+ {
+ url = url + "?nameOrEmail=" + WebUtility.UrlEncode(query.NameOrEmail);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.Name))
+ {
+ url = url + "?name=" + WebUtility.UrlEncode(query.Name);
+ }
+ else if (!string.IsNullOrWhiteSpace(query.Email))
+ {
+ url = url + "?name=" + WebUtility.UrlEncode(query.Email);
+ }
+ else
+ {
+ throw new ArgumentException("Empty ConnectUserQuery supplied");
+ }
+
+ var options = new HttpRequestOptions
+ {
+ CancellationToken = cancellationToken,
+ Url = url,
+ BufferContent = false
+ };
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ using (var stream = await _httpClient.Get(options).ConfigureAwait(false))
+ {
+ var response = _json.DeserializeFromStream<GetConnectUserResponse>(stream);
+
+ return new ConnectUser
+ {
+ Email = response.Email,
+ Id = response.Id,
+ Name = response.Name,
+ IsActive = response.IsActive,
+ ImageUrl = response.ImageUrl
+ };
+ }
+ }
+
+ private void SetApplicationHeader(HttpRequestOptions options)
+ {
+ options.RequestHeaders.Add("X-Application", XApplicationValue);
+ }
+
+ private void SetServerAccessToken(HttpRequestOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectAccessKey))
+ {
+ throw new ArgumentNullException("ConnectAccessKey");
+ }
+
+ options.RequestHeaders.Add("X-Connect-Token", ConnectAccessKey);
+ }
+
+ public async Task RefreshAuthorizations(CancellationToken cancellationToken)
+ {
+ await _operationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await RefreshAuthorizationsInternal(true, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task RefreshAuthorizationsInternal(bool refreshImages, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ url += "?serverId=" + ConnectServerId;
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = cancellationToken,
+ BufferContent = false
+ };
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ try
+ {
+ using (var stream = (await _httpClient.SendAsync(options, "GET").ConfigureAwait(false)).Content)
+ {
+ var list = _json.DeserializeFromStream<List<ServerUserAuthorizationResponse>>(stream);
+
+ await RefreshAuthorizations(list, refreshImages).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing server authorizations.", ex);
+ }
+ }
+
+ private async Task RefreshAuthorizations(List<ServerUserAuthorizationResponse> list, bool refreshImages)
+ {
+ var users = _userManager.Users.ToList();
+
+ // Handle existing authorizations that were removed by the Connect server
+ // Handle existing authorizations whose status may have been updated
+ foreach (var user in users)
+ {
+ if (!string.IsNullOrWhiteSpace(user.ConnectUserId))
+ {
+ var connectEntry = list.FirstOrDefault(i => string.Equals(i.UserId, user.ConnectUserId, StringComparison.OrdinalIgnoreCase));
+
+ if (connectEntry == null)
+ {
+ var deleteUser = user.ConnectLinkType.HasValue &&
+ user.ConnectLinkType.Value == UserLinkType.Guest;
+
+ user.ConnectUserId = null;
+ user.ConnectAccessKey = null;
+ user.ConnectUserName = null;
+ user.ConnectLinkType = null;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+
+ if (deleteUser)
+ {
+ _logger.Debug("Deleting guest user {0}", user.Name);
+ await _userManager.DeleteUser(user).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ var changed = !string.Equals(user.ConnectAccessKey, connectEntry.AccessToken, StringComparison.OrdinalIgnoreCase);
+
+ if (changed)
+ {
+ user.ConnectUserId = connectEntry.UserId;
+ user.ConnectAccessKey = connectEntry.AccessToken;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+
+ var currentPendingList = _data.PendingAuthorizations.ToList();
+ var newPendingList = new List<ConnectAuthorizationInternal>();
+
+ foreach (var connectEntry in list)
+ {
+ if (string.Equals(connectEntry.UserType, "guest", StringComparison.OrdinalIgnoreCase))
+ {
+ var currentPendingEntry = currentPendingList.FirstOrDefault(i => string.Equals(i.Id, connectEntry.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (string.Equals(connectEntry.AcceptStatus, "accepted", StringComparison.OrdinalIgnoreCase))
+ {
+ var user = _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectEntry.UserId, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ {
+ // Add user
+ user = await _userManager.CreateUser(_userManager.MakeValidUsername(connectEntry.UserName)).ConfigureAwait(false);
+
+ user.ConnectUserName = connectEntry.UserName;
+ user.ConnectUserId = connectEntry.UserId;
+ user.ConnectLinkType = UserLinkType.Guest;
+ user.ConnectAccessKey = connectEntry.AccessToken;
+
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+
+ user.Policy.IsHidden = true;
+ user.Policy.EnableLiveTvManagement = false;
+ user.Policy.EnableContentDeletion = false;
+ user.Policy.EnableRemoteControlOfOtherUsers = false;
+ user.Policy.EnableSharedDeviceControl = false;
+ user.Policy.IsAdministrator = false;
+
+ if (currentPendingEntry != null)
+ {
+ user.Policy.EnabledFolders = currentPendingEntry.EnabledLibraries;
+ user.Policy.EnableAllFolders = false;
+
+ user.Policy.EnabledChannels = currentPendingEntry.EnabledChannels;
+ user.Policy.EnableAllChannels = false;
+
+ user.Policy.EnableLiveTvAccess = currentPendingEntry.EnableLiveTv;
+ }
+
+ await _userManager.UpdateConfiguration(user.Id.ToString("N"), user.Configuration);
+ }
+ }
+ else if (string.Equals(connectEntry.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase))
+ {
+ currentPendingEntry = currentPendingEntry ?? new ConnectAuthorizationInternal();
+
+ currentPendingEntry.ConnectUserId = connectEntry.UserId;
+ currentPendingEntry.ImageUrl = connectEntry.UserImageUrl;
+ currentPendingEntry.UserName = connectEntry.UserName;
+ currentPendingEntry.Id = connectEntry.Id;
+ currentPendingEntry.AccessToken = connectEntry.AccessToken;
+
+ newPendingList.Add(currentPendingEntry);
+ }
+ }
+ }
+
+ _data.PendingAuthorizations = newPendingList;
+ CacheData();
+
+ await RefreshGuestNames(list, refreshImages).ConfigureAwait(false);
+ }
+
+ private async Task RefreshGuestNames(List<ServerUserAuthorizationResponse> list, bool refreshImages)
+ {
+ var users = _userManager.Users
+ .Where(i => !string.IsNullOrEmpty(i.ConnectUserId) && i.ConnectLinkType.HasValue && i.ConnectLinkType.Value == UserLinkType.Guest)
+ .ToList();
+
+ foreach (var user in users)
+ {
+ var authorization = list.FirstOrDefault(i => string.Equals(i.UserId, user.ConnectUserId, StringComparison.Ordinal));
+
+ if (authorization == null)
+ {
+ _logger.Warn("Unable to find connect authorization record for user {0}", user.Name);
+ continue;
+ }
+
+ var syncConnectName = true;
+ var syncConnectImage = true;
+
+ if (syncConnectName)
+ {
+ var changed = !string.Equals(authorization.UserName, user.Name, StringComparison.OrdinalIgnoreCase);
+
+ if (changed)
+ {
+ await user.Rename(authorization.UserName).ConfigureAwait(false);
+ }
+ }
+
+ if (syncConnectImage)
+ {
+ var imageUrl = authorization.UserImageUrl;
+
+ if (!string.IsNullOrWhiteSpace(imageUrl))
+ {
+ var changed = false;
+
+ if (!user.HasImage(ImageType.Primary))
+ {
+ changed = true;
+ }
+ else if (refreshImages)
+ {
+ using (var response = await _httpClient.SendAsync(new HttpRequestOptions
+ {
+ Url = imageUrl,
+ BufferContent = false
+
+ }, "HEAD").ConfigureAwait(false))
+ {
+ var length = response.ContentLength;
+
+ if (length != _fileSystem.GetFileInfo(user.GetImageInfo(ImageType.Primary, 0).Path).Length)
+ {
+ changed = true;
+ }
+ }
+ }
+
+ if (changed)
+ {
+ await _providerManager.SaveImage(user, imageUrl, null, ImageType.Primary, null, CancellationToken.None).ConfigureAwait(false);
+
+ await user.RefreshMetadata(new MetadataRefreshOptions(_fileSystem)
+ {
+ ForceSave = true,
+
+ }, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+ }
+
+ public async Task<List<ConnectAuthorization>> GetPendingGuests()
+ {
+ var time = DateTime.UtcNow - _data.LastAuthorizationsRefresh;
+
+ if (time.TotalMinutes >= 5)
+ {
+ await _operationLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+
+ _data.LastAuthorizationsRefresh = DateTime.UtcNow;
+ CacheData();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing authorization", ex);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ return _data.PendingAuthorizations.Select(i => new ConnectAuthorization
+ {
+ ConnectUserId = i.ConnectUserId,
+ EnableLiveTv = i.EnableLiveTv,
+ EnabledChannels = i.EnabledChannels,
+ EnabledLibraries = i.EnabledLibraries,
+ Id = i.Id,
+ ImageUrl = i.ImageUrl,
+ UserName = i.UserName
+
+ }).ToList();
+ }
+
+ public async Task CancelAuthorization(string id)
+ {
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ await CancelAuthorizationInternal(id).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationLock.Release();
+ }
+ }
+
+ private async Task CancelAuthorizationInternal(string id)
+ {
+ var connectUserId = _data.PendingAuthorizations
+ .First(i => string.Equals(i.Id, id, StringComparison.Ordinal))
+ .ConnectUserId;
+
+ await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false);
+
+ await RefreshAuthorizationsInternal(false, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task CancelAuthorizationByConnectUserId(string connectUserId)
+ {
+ if (string.IsNullOrWhiteSpace(connectUserId))
+ {
+ throw new ArgumentNullException("connectUserId");
+ }
+ if (string.IsNullOrWhiteSpace(ConnectServerId))
+ {
+ throw new ArgumentNullException("ConnectServerId");
+ }
+
+ var url = GetConnectUrl("ServerAuthorizations");
+
+ var options = new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+
+ var postData = new Dictionary<string, string>
+ {
+ {"serverId", ConnectServerId},
+ {"userId", connectUserId}
+ };
+
+ options.SetPostData(postData);
+
+ SetServerAccessToken(options);
+ SetApplicationHeader(options);
+
+ try
+ {
+ // No need to examine the response
+ using (var stream = (await _httpClient.SendAsync(options, "DELETE").ConfigureAwait(false)).Content)
+ {
+ }
+ }
+ catch (HttpException ex)
+ {
+ // If connect says the auth doesn't exist, we can handle that gracefully since this is a remove operation
+
+ if (!ex.StatusCode.HasValue || ex.StatusCode.Value != HttpStatusCode.NotFound)
+ {
+ throw;
+ }
+
+ _logger.Debug("Connect returned a 404 when removing a user auth link. Handling it.");
+ }
+ }
+
+ public async Task Authenticate(string username, string passwordMd5)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new ArgumentNullException("username");
+ }
+
+ if (string.IsNullOrWhiteSpace(passwordMd5))
+ {
+ throw new ArgumentNullException("passwordMd5");
+ }
+
+ var options = new HttpRequestOptions
+ {
+ Url = GetConnectUrl("user/authenticate"),
+ BufferContent = false
+ };
+
+ options.SetPostData(new Dictionary<string, string>
+ {
+ {"userName",username},
+ {"password",passwordMd5}
+ });
+
+ SetApplicationHeader(options);
+
+ // No need to examine the response
+ using (var response = (await _httpClient.SendAsync(options, "POST").ConfigureAwait(false)).Content)
+ {
+ }
+ }
+
+ public async Task<User> GetLocalUser(string connectUserId)
+ {
+ var user = _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUserId, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ {
+ await RefreshAuthorizations(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ return _userManager.Users
+ .FirstOrDefault(i => string.Equals(i.ConnectUserId, connectUserId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public User GetUserFromExchangeToken(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentNullException("token");
+ }
+
+ return _userManager.Users.FirstOrDefault(u => string.Equals(token, u.ConnectAccessKey, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public bool IsAuthorizationTokenValid(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentNullException("token");
+ }
+
+ return _userManager.Users.Any(u => string.Equals(token, u.ConnectAccessKey, StringComparison.OrdinalIgnoreCase)) ||
+ _data.PendingAuthorizations.Select(i => i.AccessToken).Contains(token, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/Responses.cs b/Emby.Server.Implementations/Connect/Responses.cs
new file mode 100644
index 000000000..87cb6cdf9
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/Responses.cs
@@ -0,0 +1,85 @@
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Connect;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public class ServerRegistrationResponse
+ {
+ public string Id { get; set; }
+ public string Url { get; set; }
+ public string Name { get; set; }
+ public string AccessKey { get; set; }
+ }
+
+ public class UpdateServerRegistrationResponse
+ {
+ public string Id { get; set; }
+ public string Url { get; set; }
+ public string Name { get; set; }
+ }
+
+ public class GetConnectUserResponse
+ {
+ public string Id { get; set; }
+ public string Name { get; set; }
+ public string DisplayName { get; set; }
+ public string Email { get; set; }
+ public bool IsActive { get; set; }
+ public string ImageUrl { get; set; }
+ }
+
+ public class ServerUserAuthorizationResponse
+ {
+ public string Id { get; set; }
+ public string ServerId { get; set; }
+ public string UserId { get; set; }
+ public string AccessToken { get; set; }
+ public string DateCreated { get; set; }
+ public bool IsActive { get; set; }
+ public string AcceptStatus { get; set; }
+ public string UserType { get; set; }
+ public string UserImageUrl { get; set; }
+ public string UserName { get; set; }
+ }
+
+ public class ConnectUserPreferences
+ {
+ public string[] PreferredAudioLanguages { get; set; }
+ public bool PlayDefaultAudioTrack { get; set; }
+ public string[] PreferredSubtitleLanguages { get; set; }
+ public SubtitlePlaybackMode SubtitleMode { get; set; }
+ public bool GroupMoviesIntoBoxSets { get; set; }
+
+ public ConnectUserPreferences()
+ {
+ PreferredAudioLanguages = new string[] { };
+ PreferredSubtitleLanguages = new string[] { };
+ }
+
+ public static ConnectUserPreferences FromUserConfiguration(UserConfiguration config)
+ {
+ return new ConnectUserPreferences
+ {
+ PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
+ SubtitleMode = config.SubtitleMode,
+ PreferredAudioLanguages = string.IsNullOrWhiteSpace(config.AudioLanguagePreference) ? new string[] { } : new[] { config.AudioLanguagePreference },
+ PreferredSubtitleLanguages = string.IsNullOrWhiteSpace(config.SubtitleLanguagePreference) ? new string[] { } : new[] { config.SubtitleLanguagePreference }
+ };
+ }
+
+ public void MergeInto(UserConfiguration config)
+ {
+
+ }
+ }
+
+ public class UserPreferencesDto<T>
+ {
+ public T data { get; set; }
+ }
+
+ public class ConnectAuthorizationInternal : ConnectAuthorization
+ {
+ public string AccessToken { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/Connect/Validator.cs b/Emby.Server.Implementations/Connect/Validator.cs
new file mode 100644
index 000000000..5c94fa71c
--- /dev/null
+++ b/Emby.Server.Implementations/Connect/Validator.cs
@@ -0,0 +1,29 @@
+using System.Text.RegularExpressions;
+
+namespace Emby.Server.Implementations.Connect
+{
+ public static class Validator
+ {
+ static readonly Regex ValidEmailRegex = CreateValidEmailRegex();
+
+ /// <summary>
+ /// Taken from http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx
+ /// </summary>
+ /// <returns></returns>
+ private static Regex CreateValidEmailRegex()
+ {
+ const string validEmailPattern = @"^(?!\.)(""([^""\r\\]|\\[""\r\\])*""|"
+ + @"([-a-z0-9!#$%&'*+/=?^_`{|}~]|(?<!\.)\.)*)(?<!\.)"
+ + @"@[a-z0-9][\w\.-]*[a-z0-9]\.[a-z][a-z\.]*[a-z]$";
+
+ return new Regex(validEmailPattern, RegexOptions.IgnoreCase);
+ }
+
+ internal static bool EmailIsValid(string emailAddress)
+ {
+ bool isValid = ValidEmailRegex.IsMatch(emailAddress);
+
+ return isValid;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
new file mode 100644
index 000000000..64a0d889e
--- /dev/null
+++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
@@ -0,0 +1,401 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Logging;
+using SQLitePCL.pretty;
+using System.Linq;
+using SQLitePCL;
+
+namespace Emby.Server.Implementations.Data
+{
+ public abstract class BaseSqliteRepository : IDisposable
+ {
+ protected string DbFilePath { get; set; }
+ protected ReaderWriterLockSlim WriteLock;
+
+ protected ILogger Logger { get; private set; }
+
+ protected BaseSqliteRepository(ILogger logger)
+ {
+ Logger = logger;
+
+ WriteLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
+ }
+
+ protected TransactionMode TransactionMode
+ {
+ get { return TransactionMode.Deferred; }
+ }
+
+ protected TransactionMode ReadTransactionMode
+ {
+ get { return TransactionMode.Deferred; }
+ }
+
+ internal static int ThreadSafeMode { get; set; }
+
+ static BaseSqliteRepository()
+ {
+ SQLite3.EnableSharedCache = false;
+
+ int rc = raw.sqlite3_config(raw.SQLITE_CONFIG_MEMSTATUS, 0);
+ //CheckOk(rc);
+
+ rc = raw.sqlite3_config(raw.SQLITE_CONFIG_MULTITHREAD, 1);
+ //rc = raw.sqlite3_config(raw.SQLITE_CONFIG_SINGLETHREAD, 1);
+ //rc = raw.sqlite3_config(raw.SQLITE_CONFIG_SERIALIZED, 1);
+ //CheckOk(rc);
+
+ rc = raw.sqlite3_enable_shared_cache(1);
+
+ ThreadSafeMode = raw.sqlite3_threadsafe();
+ }
+
+ private static bool _versionLogged;
+
+ private string _defaultWal;
+ protected ManagedConnection _connection;
+
+ protected virtual bool EnableSingleConnection
+ {
+ get { return true; }
+ }
+
+ protected ManagedConnection CreateConnection(bool isReadOnly = false)
+ {
+ if (_connection != null)
+ {
+ return _connection;
+ }
+
+ lock (WriteLock)
+ {
+ if (!_versionLogged)
+ {
+ _versionLogged = true;
+ Logger.Info("Sqlite version: " + SQLite3.Version);
+ Logger.Info("Sqlite compiler options: " + string.Join(",", SQLite3.CompilerOptions.ToArray()));
+ }
+
+ ConnectionFlags connectionFlags;
+
+ if (isReadOnly)
+ {
+ //Logger.Info("Opening read connection");
+ //connectionFlags = ConnectionFlags.ReadOnly;
+ connectionFlags = ConnectionFlags.Create;
+ connectionFlags |= ConnectionFlags.ReadWrite;
+ }
+ else
+ {
+ //Logger.Info("Opening write connection");
+ connectionFlags = ConnectionFlags.Create;
+ connectionFlags |= ConnectionFlags.ReadWrite;
+ }
+
+ if (EnableSingleConnection)
+ {
+ connectionFlags |= ConnectionFlags.PrivateCache;
+ }
+ else
+ {
+ connectionFlags |= ConnectionFlags.SharedCached;
+ }
+
+ connectionFlags |= ConnectionFlags.NoMutex;
+
+ var db = SQLite3.Open(DbFilePath, connectionFlags, null);
+
+ if (string.IsNullOrWhiteSpace(_defaultWal))
+ {
+ _defaultWal = db.Query("PRAGMA journal_mode").SelectScalarString().First();
+
+ Logger.Info("Default journal_mode for {0} is {1}", DbFilePath, _defaultWal);
+ }
+
+ var queries = new List<string>
+ {
+ //"PRAGMA cache size=-10000"
+ //"PRAGMA read_uncommitted = true",
+ "PRAGMA synchronous=Normal"
+ };
+
+ if (CacheSize.HasValue)
+ {
+ queries.Add("PRAGMA cache_size=-" + CacheSize.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (EnableTempStoreMemory)
+ {
+ queries.Add("PRAGMA temp_store = memory");
+ }
+
+ //var cacheSize = CacheSize;
+ //if (cacheSize.HasValue)
+ //{
+
+ //}
+
+ ////foreach (var query in queries)
+ ////{
+ //// db.Execute(query);
+ ////}
+
+ //Logger.Info("synchronous: " + db.Query("PRAGMA synchronous").SelectScalarString().First());
+ //Logger.Info("temp_store: " + db.Query("PRAGMA temp_store").SelectScalarString().First());
+
+ /*if (!string.Equals(_defaultWal, "wal", StringComparison.OrdinalIgnoreCase))
+ {
+ queries.Add("PRAGMA journal_mode=WAL");
+
+ using (WriteLock.Write())
+ {
+ db.ExecuteAll(string.Join(";", queries.ToArray()));
+ }
+ }
+ else*/
+ foreach (var query in queries)
+ {
+ db.Execute(query);
+ }
+
+ _connection = new ManagedConnection(db, false);
+
+ return _connection;
+ }
+ }
+
+ public IStatement PrepareStatement(ManagedConnection connection, string sql)
+ {
+ return connection.PrepareStatement(sql);
+ }
+
+ public IStatement PrepareStatementSafe(ManagedConnection connection, string sql)
+ {
+ return connection.PrepareStatement(sql);
+ }
+
+ public IStatement PrepareStatement(IDatabaseConnection connection, string sql)
+ {
+ return connection.PrepareStatement(sql);
+ }
+
+ public IStatement PrepareStatementSafe(IDatabaseConnection connection, string sql)
+ {
+ return connection.PrepareStatement(sql);
+ }
+
+ public List<IStatement> PrepareAll(IDatabaseConnection connection, IEnumerable<string> sql)
+ {
+ return PrepareAllSafe(connection, sql);
+ }
+
+ public List<IStatement> PrepareAllSafe(IDatabaseConnection connection, IEnumerable<string> sql)
+ {
+ return sql.Select(connection.PrepareStatement).ToList();
+ }
+
+ protected void RunDefaultInitialization(ManagedConnection db)
+ {
+ var queries = new List<string>
+ {
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA page_size=4096",
+ "PRAGMA synchronous=Normal"
+ };
+
+ if (EnableTempStoreMemory)
+ {
+ queries.AddRange(new List<string>
+ {
+ "pragma default_temp_store = memory",
+ "pragma temp_store = memory"
+ });
+ }
+
+ db.ExecuteAll(string.Join(";", queries.ToArray()));
+ Logger.Info("PRAGMA synchronous=" + db.Query("PRAGMA synchronous").SelectScalarString().First());
+ }
+
+ protected virtual bool EnableTempStoreMemory
+ {
+ get
+ {
+ return false;
+ }
+ }
+
+ protected virtual int? CacheSize
+ {
+ get
+ {
+ return null;
+ }
+ }
+
+ internal static void CheckOk(int rc)
+ {
+ string msg = "";
+
+ if (raw.SQLITE_OK != rc)
+ {
+ throw CreateException((ErrorCode)rc, msg);
+ }
+ }
+
+ internal static Exception CreateException(ErrorCode rc, string msg)
+ {
+ var exp = new Exception(msg);
+
+ return exp;
+ }
+
+ private bool _disposed;
+ protected void CheckDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(GetType().Name + " has been disposed and cannot be accessed.");
+ }
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private readonly object _disposeLock = new object();
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ try
+ {
+ lock (_disposeLock)
+ {
+ using (WriteLock.Write())
+ {
+ if (_connection != null)
+ {
+ using (_connection)
+ {
+
+ }
+ _connection = null;
+ }
+
+ CloseConnection();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error disposing database", ex);
+ }
+ }
+ }
+
+ protected virtual void CloseConnection()
+ {
+
+ }
+
+ protected List<string> GetColumnNames(IDatabaseConnection connection, string table)
+ {
+ var list = new List<string>();
+
+ foreach (var row in connection.Query("PRAGMA table_info(" + table + ")"))
+ {
+ if (row[1].SQLiteType != SQLiteType.Null)
+ {
+ var name = row[1].ToString();
+
+ list.Add(name);
+ }
+ }
+
+ return list;
+ }
+
+ protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
+ {
+ if (existingColumnNames.Contains(columnName, StringComparer.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ connection.Execute("alter table " + table + " add column " + columnName + " " + type + " NULL");
+ }
+ }
+
+ public static class ReaderWriterLockSlimExtensions
+ {
+ private sealed class ReadLockToken : IDisposable
+ {
+ private ReaderWriterLockSlim _sync;
+ public ReadLockToken(ReaderWriterLockSlim sync)
+ {
+ _sync = sync;
+ sync.EnterReadLock();
+ }
+ public void Dispose()
+ {
+ if (_sync != null)
+ {
+ _sync.ExitReadLock();
+ _sync = null;
+ }
+ }
+ }
+ private sealed class WriteLockToken : IDisposable
+ {
+ private ReaderWriterLockSlim _sync;
+ public WriteLockToken(ReaderWriterLockSlim sync)
+ {
+ _sync = sync;
+ sync.EnterWriteLock();
+ }
+ public void Dispose()
+ {
+ if (_sync != null)
+ {
+ _sync.ExitWriteLock();
+ _sync = null;
+ }
+ }
+ }
+
+ public class DummyToken : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+
+ public static IDisposable Read(this ReaderWriterLockSlim obj)
+ {
+ //if (BaseSqliteRepository.ThreadSafeMode > 0)
+ //{
+ // return new DummyToken();
+ //}
+ return new WriteLockToken(obj);
+ }
+ public static IDisposable Write(this ReaderWriterLockSlim obj)
+ {
+ //if (BaseSqliteRepository.ThreadSafeMode > 0)
+ //{
+ // return new DummyToken();
+ //}
+ return new WriteLockToken(obj);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
new file mode 100644
index 000000000..5bc3a625f
--- /dev/null
+++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
@@ -0,0 +1,217 @@
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Data
+{
+ public class CleanDatabaseScheduledTask : IScheduledTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IItemRepository _itemRepo;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+
+ public CleanDatabaseScheduledTask(ILibraryManager libraryManager, IItemRepository itemRepo, ILogger logger, IFileSystem fileSystem)
+ {
+ _libraryManager = libraryManager;
+ _itemRepo = itemRepo;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ public string Name
+ {
+ get { return "Clean Database"; }
+ }
+
+ public string Description
+ {
+ get { return "Deletes obsolete content from the database."; }
+ }
+
+ public string Category
+ {
+ get { return "Library"; }
+ }
+
+ public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ // Ensure these objects are lazy loaded.
+ // Without this there is a deadlock that will need to be investigated
+ var rootChildren = _libraryManager.RootFolder.Children.ToList();
+ rootChildren = _libraryManager.GetUserRootFolder().Children.ToList();
+
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p =>
+ {
+ double newPercentCommplete = .45 * p;
+ progress.Report(newPercentCommplete);
+ });
+ await CleanDeadItems(cancellationToken, innerProgress).ConfigureAwait(false);
+ progress.Report(45);
+
+ innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p =>
+ {
+ double newPercentCommplete = 45 + .55 * p;
+ progress.Report(newPercentCommplete);
+ });
+ await CleanDeletedItems(cancellationToken, innerProgress).ConfigureAwait(false);
+ progress.Report(100);
+
+ await _itemRepo.UpdateInheritedValues(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ HasDeadParentId = true
+ });
+
+ var numComplete = 0;
+ var numItems = itemIds.Count;
+
+ _logger.Debug("Cleaning {0} items with dead parent links", numItems);
+
+ foreach (var itemId in itemIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item != null)
+ {
+ _logger.Info("Cleaning item {0} type: {1} path: {2}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
+
+ await item.Delete(new DeleteOptions
+ {
+ DeleteFileLocation = false
+
+ }).ConfigureAwait(false);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= numItems;
+ progress.Report(percent * 100);
+ }
+
+ progress.Report(100);
+ }
+
+ private async Task CleanDeletedItems(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var result = _itemRepo.GetItemIdsWithPath(new InternalItemsQuery
+ {
+ LocationTypes = new[] { LocationType.FileSystem },
+ //Limit = limit,
+
+ // These have their own cleanup routines
+ ExcludeItemTypes = new[]
+ {
+ typeof(Person).Name,
+ typeof(Genre).Name,
+ typeof(MusicGenre).Name,
+ typeof(GameGenre).Name,
+ typeof(Studio).Name,
+ typeof(Year).Name,
+ typeof(Channel).Name,
+ typeof(AggregateFolder).Name,
+ typeof(CollectionFolder).Name
+ }
+ });
+
+ var numComplete = 0;
+ var numItems = result.Count;
+
+ foreach (var item in result)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var path = item.Item2;
+
+ try
+ {
+ if (_fileSystem.FileExists(path) || _fileSystem.DirectoryExists(path))
+ {
+ continue;
+ }
+
+ var libraryItem = _libraryManager.GetItemById(item.Item1);
+
+ if (libraryItem.IsTopParent)
+ {
+ continue;
+ }
+
+ var hasDualAccess = libraryItem as IHasDualAccess;
+ if (hasDualAccess != null && hasDualAccess.IsAccessedByName)
+ {
+ continue;
+ }
+
+ var libraryItemPath = libraryItem.Path;
+ if (!string.Equals(libraryItemPath, path, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Error("CleanDeletedItems aborting delete for item {0}-{1} because paths don't match. {2}---{3}", libraryItem.Id, libraryItem.Name, libraryItem.Path ?? string.Empty, path ?? string.Empty);
+ continue;
+ }
+
+ if (Folder.IsPathOffline(path))
+ {
+ await libraryItem.UpdateIsOffline(true).ConfigureAwait(false);
+ continue;
+ }
+
+ _logger.Info("Deleting item from database {0} because path no longer exists. type: {1} path: {2}", libraryItem.Name, libraryItem.GetType().Name, libraryItemPath ?? string.Empty);
+
+ await libraryItem.OnFileDeleted().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in CleanDeletedItems. File {0}", ex, path);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= numItems;
+ progress.Report(percent * 100);
+ }
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks}
+ };
+ }
+
+ public string Key
+ {
+ get { return "CleanDatabase"; }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs
new file mode 100644
index 000000000..91a2dfdf6
--- /dev/null
+++ b/Emby.Server.Implementations/Data/ManagedConnection.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ public class ManagedConnection : IDisposable
+ {
+ private SQLiteDatabaseConnection db;
+ private readonly bool _closeOnDispose;
+
+ public ManagedConnection(SQLiteDatabaseConnection db, bool closeOnDispose)
+ {
+ this.db = db;
+ _closeOnDispose = closeOnDispose;
+ }
+
+ public IStatement PrepareStatement(string sql)
+ {
+ return db.PrepareStatement(sql);
+ }
+
+ public IEnumerable<IStatement> PrepareAll(string sql)
+ {
+ return db.PrepareAll(sql);
+ }
+
+ public void ExecuteAll(string sql)
+ {
+ db.ExecuteAll(sql);
+ }
+
+ public void Execute(string sql, params object[] values)
+ {
+ db.Execute(sql, values);
+ }
+
+ public void RunQueries(string[] sql)
+ {
+ db.RunQueries(sql);
+ }
+
+ public void RunInTransaction(Action<IDatabaseConnection> action, TransactionMode mode)
+ {
+ db.RunInTransaction(action, mode);
+ }
+
+ public T RunInTransaction<T>(Func<IDatabaseConnection, T> action, TransactionMode mode)
+ {
+ return db.RunInTransaction<T>(action, mode);
+ }
+
+ public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql)
+ {
+ return db.Query(sql);
+ }
+
+ public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql, params object[] values)
+ {
+ return db.Query(sql, values);
+ }
+
+ public void Close()
+ {
+ using (db)
+ {
+
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_closeOnDispose)
+ {
+ Close();
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs b/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs
new file mode 100644
index 000000000..f3d84315e
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ /// <summary>
+ /// Class SQLiteDisplayPreferencesRepository
+ /// </summary>
+ public class SqliteDisplayPreferencesRepository : BaseSqliteRepository, IDisplayPreferencesRepository
+ {
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ public SqliteDisplayPreferencesRepository(ILogger logger, IJsonSerializer jsonSerializer, IApplicationPaths appPaths, IMemoryStreamFactory memoryStreamProvider)
+ : base(logger)
+ {
+ _jsonSerializer = jsonSerializer;
+ _memoryStreamProvider = memoryStreamProvider;
+ DbFilePath = Path.Combine(appPaths.DataPath, "displaypreferences.db");
+ }
+
+ /// <summary>
+ /// Gets the name of the repository
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get
+ {
+ return "SQLite";
+ }
+ }
+
+ /// <summary>
+ /// The _json serializer
+ /// </summary>
+ private readonly IJsonSerializer _jsonSerializer;
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists userdisplaypreferences (id GUID, userId GUID, client text, data BLOB)",
+ "create unique index if not exists userdisplaypreferencesindex on userdisplaypreferences (id, userId, client)"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ /// <summary>
+ /// Save the display preferences associated with an item in the repo
+ /// </summary>
+ /// <param name="displayPreferences">The display preferences.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="client">The client.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public async Task SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, CancellationToken cancellationToken)
+ {
+ if (displayPreferences == null)
+ {
+ throw new ArgumentNullException("displayPreferences");
+ }
+ if (string.IsNullOrWhiteSpace(displayPreferences.Id))
+ {
+ throw new ArgumentNullException("displayPreferences.Id");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ SaveDisplayPreferences(displayPreferences, userId, client, db);
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, IDatabaseConnection connection)
+ {
+ var serialized = _jsonSerializer.SerializeToBytes(displayPreferences, _memoryStreamProvider);
+
+ using (var statement = connection.PrepareStatement("replace into userdisplaypreferences (id, userid, client, data) values (@id, @userId, @client, @data)"))
+ {
+ statement.TryBind("@id", displayPreferences.Id.ToGuidParamValue());
+ statement.TryBind("@userId", userId.ToGuidParamValue());
+ statement.TryBind("@client", client);
+ statement.TryBind("@data", serialized);
+
+ statement.MoveNext();
+ }
+ }
+
+ /// <summary>
+ /// Save all display preferences associated with a user in the repo
+ /// </summary>
+ /// <param name="displayPreferences">The display preferences.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public async Task SaveAllDisplayPreferences(IEnumerable<DisplayPreferences> displayPreferences, Guid userId, CancellationToken cancellationToken)
+ {
+ if (displayPreferences == null)
+ {
+ throw new ArgumentNullException("displayPreferences");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ foreach (var displayPreference in displayPreferences)
+ {
+ SaveDisplayPreferences(displayPreference, userId, displayPreference.Client, db);
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the display preferences.
+ /// </summary>
+ /// <param name="displayPreferencesId">The display preferences id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="client">The client.</param>
+ /// <returns>Task{DisplayPreferences}.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, Guid userId, string client)
+ {
+ if (string.IsNullOrWhiteSpace(displayPreferencesId))
+ {
+ throw new ArgumentNullException("displayPreferencesId");
+ }
+
+ var guidId = displayPreferencesId.GetMD5();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where id = @id and userId=@userId and client=@client"))
+ {
+ statement.TryBind("@id", guidId.ToGuidParamValue());
+ statement.TryBind("@userId", userId.ToGuidParamValue());
+ statement.TryBind("@client", client);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return Get(row);
+ }
+ }
+
+ return new DisplayPreferences
+ {
+ Id = guidId.ToString("N")
+ };
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets all display preferences for the given user.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <returns>Task{DisplayPreferences}.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId)
+ {
+ var list = new List<DisplayPreferences>();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where userId=@userId"))
+ {
+ statement.TryBind("@userId", userId.ToGuidParamValue());
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(Get(row));
+ }
+ }
+ }
+ }
+
+ return list;
+ }
+
+ private DisplayPreferences Get(IReadOnlyList<IResultSetValue> row)
+ {
+ using (var stream = _memoryStreamProvider.CreateNew(row[0].ToBlob()))
+ {
+ stream.Position = 0;
+ return _jsonSerializer.DeserializeFromStream<DisplayPreferences>(stream);
+ }
+ }
+
+ public Task SaveDisplayPreferences(DisplayPreferences displayPreferences, string userId, string client, CancellationToken cancellationToken)
+ {
+ return SaveDisplayPreferences(displayPreferences, new Guid(userId), client, cancellationToken);
+ }
+
+ public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client)
+ {
+ return GetDisplayPreferences(displayPreferencesId, new Guid(userId), client);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs
new file mode 100644
index 000000000..d6ad0ba8a
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs
@@ -0,0 +1,394 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Serialization;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ public static class SqliteExtensions
+ {
+ public static void RunQueries(this SQLiteDatabaseConnection connection, string[] queries)
+ {
+ if (queries == null)
+ {
+ throw new ArgumentNullException("queries");
+ }
+
+ connection.RunInTransaction(conn =>
+ {
+ //foreach (var query in queries)
+ //{
+ // conn.Execute(query);
+ //}
+ conn.ExecuteAll(string.Join(";", queries));
+ });
+ }
+
+ public static byte[] ToGuidParamValue(this string str)
+ {
+ return ToGuidParamValue(new Guid(str));
+ }
+
+ public static byte[] ToGuidParamValue(this Guid guid)
+ {
+ return guid.ToByteArray();
+ }
+
+ public static Guid ReadGuid(this IResultSetValue result)
+ {
+ return new Guid(result.ToBlob());
+ }
+
+ public static string ToDateTimeParamValue(this DateTime dateValue)
+ {
+ var kind = DateTimeKind.Utc;
+
+ return (dateValue.Kind == DateTimeKind.Unspecified)
+ ? DateTime.SpecifyKind(dateValue, kind).ToString(
+ GetDateTimeKindFormat(kind),
+ CultureInfo.InvariantCulture)
+ : dateValue.ToString(
+ GetDateTimeKindFormat(dateValue.Kind),
+ CultureInfo.InvariantCulture);
+ }
+
+ private static string GetDateTimeKindFormat(
+ DateTimeKind kind)
+ {
+ return (kind == DateTimeKind.Utc) ? _datetimeFormatUtc : _datetimeFormatLocal;
+ }
+
+ /// <summary>
+ /// An array of ISO-8601 DateTime formats that we support parsing.
+ /// </summary>
+ private static string[] _datetimeFormats = new string[] {
+ "THHmmssK",
+ "THHmmK",
+ "HH:mm:ss.FFFFFFFK",
+ "HH:mm:ssK",
+ "HH:mmK",
+ "yyyy-MM-dd HH:mm:ss.FFFFFFFK", /* NOTE: UTC default (5). */
+ "yyyy-MM-dd HH:mm:ssK",
+ "yyyy-MM-dd HH:mmK",
+ "yyyy-MM-ddTHH:mm:ss.FFFFFFFK",
+ "yyyy-MM-ddTHH:mmK",
+ "yyyy-MM-ddTHH:mm:ssK",
+ "yyyyMMddHHmmssK",
+ "yyyyMMddHHmmK",
+ "yyyyMMddTHHmmssFFFFFFFK",
+ "THHmmss",
+ "THHmm",
+ "HH:mm:ss.FFFFFFF",
+ "HH:mm:ss",
+ "HH:mm",
+ "yyyy-MM-dd HH:mm:ss.FFFFFFF", /* NOTE: Non-UTC default (19). */
+ "yyyy-MM-dd HH:mm:ss",
+ "yyyy-MM-dd HH:mm",
+ "yyyy-MM-ddTHH:mm:ss.FFFFFFF",
+ "yyyy-MM-ddTHH:mm",
+ "yyyy-MM-ddTHH:mm:ss",
+ "yyyyMMddHHmmss",
+ "yyyyMMddHHmm",
+ "yyyyMMddTHHmmssFFFFFFF",
+ "yyyy-MM-dd",
+ "yyyyMMdd",
+ "yy-MM-dd"
+ };
+
+ private static string _datetimeFormatUtc = _datetimeFormats[5];
+ private static string _datetimeFormatLocal = _datetimeFormats[19];
+
+ public static DateTime ReadDateTime(this IResultSetValue result)
+ {
+ var dateText = result.ToString();
+
+ return DateTime.ParseExact(
+ dateText, _datetimeFormats,
+ DateTimeFormatInfo.InvariantInfo,
+ DateTimeStyles.None).ToUniversalTime();
+ }
+
+ /// <summary>
+ /// Serializes to bytes.
+ /// </summary>
+ /// <returns>System.Byte[][].</returns>
+ /// <exception cref="System.ArgumentNullException">obj</exception>
+ public static byte[] SerializeToBytes(this IJsonSerializer json, object obj, IMemoryStreamFactory streamProvider)
+ {
+ if (obj == null)
+ {
+ throw new ArgumentNullException("obj");
+ }
+
+ using (var stream = streamProvider.CreateNew())
+ {
+ json.SerializeToStream(obj, stream);
+ return stream.ToArray();
+ }
+ }
+
+ public static void Attach(ManagedConnection db, string path, string alias)
+ {
+ var commandText = string.Format("attach @path as {0};", alias);
+
+ using (var statement = db.PrepareStatement(commandText))
+ {
+ statement.TryBind("@path", path);
+ statement.MoveNext();
+ }
+ }
+
+ public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].SQLiteType == SQLiteType.Null;
+ }
+
+ public static string GetString(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ToString();
+ }
+
+ public static bool GetBoolean(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ToBool();
+ }
+
+ public static int GetInt32(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ToInt();
+ }
+
+ public static long GetInt64(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ToInt64();
+ }
+
+ public static float GetFloat(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ToFloat();
+ }
+
+ public static Guid GetGuid(this IReadOnlyList<IResultSetValue> result, int index)
+ {
+ return result[index].ReadGuid();
+ }
+
+ private static void CheckName(string name)
+ {
+#if DEBUG
+ //if (!name.IndexOf("@", StringComparison.OrdinalIgnoreCase) != 0)
+ {
+ throw new Exception("Invalid param name: " + name);
+ }
+#endif
+ }
+
+ public static void TryBind(this IStatement statement, string name, double value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, string value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ if (value == null)
+ {
+ bindParam.BindNull();
+ }
+ else
+ {
+ bindParam.Bind(value);
+ }
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, bool value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, float value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, int value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, Guid value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value.ToGuidParamValue());
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, DateTime value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value.ToDateTimeParamValue());
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, long value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, byte[] value)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.Bind(value);
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBindNull(this IStatement statement, string name)
+ {
+ IBindParameter bindParam;
+ if (statement.BindParameters.TryGetValue(name, out bindParam))
+ {
+ bindParam.BindNull();
+ }
+ else
+ {
+ CheckName(name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, DateTime? value)
+ {
+ if (value.HasValue)
+ {
+ TryBind(statement, name, value.Value);
+ }
+ else
+ {
+ TryBindNull(statement, name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, Guid? value)
+ {
+ if (value.HasValue)
+ {
+ TryBind(statement, name, value.Value);
+ }
+ else
+ {
+ TryBindNull(statement, name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, int? value)
+ {
+ if (value.HasValue)
+ {
+ TryBind(statement, name, value.Value);
+ }
+ else
+ {
+ TryBindNull(statement, name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, float? value)
+ {
+ if (value.HasValue)
+ {
+ TryBind(statement, name, value.Value);
+ }
+ else
+ {
+ TryBindNull(statement, name);
+ }
+ }
+
+ public static void TryBind(this IStatement statement, string name, bool? value)
+ {
+ if (value.HasValue)
+ {
+ TryBind(statement, name, value.Value);
+ }
+ else
+ {
+ TryBindNull(statement, name);
+ }
+ }
+
+ public static IEnumerable<IReadOnlyList<IResultSetValue>> ExecuteQuery(
+ this IStatement This)
+ {
+ while (This.MoveNext())
+ {
+ yield return This.Current;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Data/SqliteFileOrganizationRepository.cs b/Emby.Server.Implementations/Data/SqliteFileOrganizationRepository.cs
new file mode 100644
index 000000000..9fbe8669d
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteFileOrganizationRepository.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ public class SqliteFileOrganizationRepository : BaseSqliteRepository, IFileOrganizationRepository, IDisposable
+ {
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ public SqliteFileOrganizationRepository(ILogger logger, IServerApplicationPaths appPaths) : base(logger)
+ {
+ DbFilePath = Path.Combine(appPaths.DataPath, "fileorganization.db");
+ }
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists FileOrganizerResults (ResultId GUID PRIMARY KEY, OriginalPath TEXT, TargetPath TEXT, FileLength INT, OrganizationDate datetime, Status TEXT, OrganizationType TEXT, StatusMessage TEXT, ExtractedName TEXT, ExtractedYear int null, ExtractedSeasonNumber int null, ExtractedEpisodeNumber int null, ExtractedEndingEpisodeNumber, DuplicatePaths TEXT int null)",
+ "create index if not exists idx_FileOrganizerResults on FileOrganizerResults(ResultId)"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ public async Task SaveResult(FileOrganizationResult result, CancellationToken cancellationToken)
+ {
+ if (result == null)
+ {
+ throw new ArgumentNullException("result");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ var commandText = "replace into FileOrganizerResults (ResultId, OriginalPath, TargetPath, FileLength, OrganizationDate, Status, OrganizationType, StatusMessage, ExtractedName, ExtractedYear, ExtractedSeasonNumber, ExtractedEpisodeNumber, ExtractedEndingEpisodeNumber, DuplicatePaths) values (@ResultId, @OriginalPath, @TargetPath, @FileLength, @OrganizationDate, @Status, @OrganizationType, @StatusMessage, @ExtractedName, @ExtractedYear, @ExtractedSeasonNumber, @ExtractedEpisodeNumber, @ExtractedEndingEpisodeNumber, @DuplicatePaths)";
+
+ using (var statement = db.PrepareStatement(commandText))
+ {
+ statement.TryBind("@ResultId", result.Id.ToGuidParamValue());
+ statement.TryBind("@OriginalPath", result.OriginalPath);
+
+ statement.TryBind("@TargetPath", result.TargetPath);
+ statement.TryBind("@FileLength", result.FileSize);
+ statement.TryBind("@OrganizationDate", result.Date.ToDateTimeParamValue());
+ statement.TryBind("@Status", result.Status.ToString());
+ statement.TryBind("@OrganizationType", result.Type.ToString());
+ statement.TryBind("@StatusMessage", result.StatusMessage);
+ statement.TryBind("@ExtractedName", result.ExtractedName);
+ statement.TryBind("@ExtractedYear", result.ExtractedYear);
+ statement.TryBind("@ExtractedSeasonNumber", result.ExtractedSeasonNumber);
+ statement.TryBind("@ExtractedEpisodeNumber", result.ExtractedEpisodeNumber);
+ statement.TryBind("@ExtractedEndingEpisodeNumber", result.ExtractedEndingEpisodeNumber);
+ statement.TryBind("@DuplicatePaths", string.Join("|", result.DuplicatePaths.ToArray()));
+
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public async Task Delete(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("delete from FileOrganizerResults where ResultId = @ResultId"))
+ {
+ statement.TryBind("@ResultId", id.ToGuidParamValue());
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public async Task DeleteAll()
+ {
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ var commandText = "delete from FileOrganizerResults";
+
+ db.Execute(commandText);
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public QueryResult<FileOrganizationResult> GetResults(FileOrganizationResultQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = "SELECT ResultId, OriginalPath, TargetPath, FileLength, OrganizationDate, Status, OrganizationType, StatusMessage, ExtractedName, ExtractedYear, ExtractedSeasonNumber, ExtractedEpisodeNumber, ExtractedEndingEpisodeNumber, DuplicatePaths from FileOrganizerResults";
+
+ if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
+ {
+ commandText += string.Format(" WHERE ResultId NOT IN (SELECT ResultId FROM FileOrganizerResults ORDER BY OrganizationDate desc LIMIT {0})",
+ query.StartIndex.Value.ToString(_usCulture));
+ }
+
+ commandText += " ORDER BY OrganizationDate desc";
+
+ if (query.Limit.HasValue)
+ {
+ commandText += " LIMIT " + query.Limit.Value.ToString(_usCulture);
+ }
+
+ var list = new List<FileOrganizationResult>();
+
+ using (var statement = connection.PrepareStatement(commandText))
+ {
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(GetResult(row));
+ }
+ }
+
+ int count;
+ using (var statement = connection.PrepareStatement("select count (ResultId) from FileOrganizerResults"))
+ {
+ count = statement.ExecuteQuery().SelectScalarInt().First();
+ }
+
+ return new QueryResult<FileOrganizationResult>()
+ {
+ Items = list.ToArray(),
+ TotalRecordCount = count
+ };
+ }
+ }
+ }
+
+ public FileOrganizationResult GetResult(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = connection.PrepareStatement("select ResultId, OriginalPath, TargetPath, FileLength, OrganizationDate, Status, OrganizationType, StatusMessage, ExtractedName, ExtractedYear, ExtractedSeasonNumber, ExtractedEpisodeNumber, ExtractedEndingEpisodeNumber, DuplicatePaths from FileOrganizerResults where ResultId=@ResultId"))
+ {
+ statement.TryBind("@ResultId", id.ToGuidParamValue());
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return GetResult(row);
+ }
+ }
+
+ return null;
+ }
+ }
+ }
+
+ public FileOrganizationResult GetResult(IReadOnlyList<IResultSetValue> reader)
+ {
+ var index = 0;
+
+ var result = new FileOrganizationResult
+ {
+ Id = reader[0].ReadGuid().ToString("N")
+ };
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.OriginalPath = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.TargetPath = reader[index].ToString();
+ }
+
+ index++;
+ result.FileSize = reader[index].ToInt64();
+
+ index++;
+ result.Date = reader[index].ReadDateTime();
+
+ index++;
+ result.Status = (FileSortingStatus)Enum.Parse(typeof(FileSortingStatus), reader[index].ToString(), true);
+
+ index++;
+ result.Type = (FileOrganizerType)Enum.Parse(typeof(FileOrganizerType), reader[index].ToString(), true);
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.StatusMessage = reader[index].ToString();
+ }
+
+ result.OriginalFileName = Path.GetFileName(result.OriginalPath);
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.ExtractedName = reader[index].ToString();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.ExtractedYear = reader[index].ToInt();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.ExtractedSeasonNumber = reader[index].ToInt();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.ExtractedEpisodeNumber = reader[index].ToInt();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.ExtractedEndingEpisodeNumber = reader[index].ToInt();
+ }
+
+ index++;
+ if (reader[index].SQLiteType != SQLiteType.Null)
+ {
+ result.DuplicatePaths = reader[index].ToString().Split('|').Where(i => !string.IsNullOrEmpty(i)).ToList();
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
new file mode 100644
index 000000000..151702905
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -0,0 +1,5660 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Runtime.Serialization;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Extensions;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Server.Implementations.Devices;
+using MediaBrowser.Server.Implementations.Playlists;
+using MediaBrowser.Model.Reflection;
+using SQLitePCL.pretty;
+using MediaBrowser.Model.System;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Data
+{
+ /// <summary>
+ /// Class SQLiteItemRepository
+ /// </summary>
+ public class SqliteItemRepository : BaseSqliteRepository, IItemRepository
+ {
+ private readonly TypeMapper _typeMapper;
+
+ /// <summary>
+ /// Gets the name of the repository
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get
+ {
+ return "SQLite";
+ }
+ }
+
+ /// <summary>
+ /// Gets the json serializer.
+ /// </summary>
+ /// <value>The json serializer.</value>
+ private readonly IJsonSerializer _jsonSerializer;
+
+ /// <summary>
+ /// The _app paths
+ /// </summary>
+ private readonly IServerConfigurationManager _config;
+
+ private readonly string _criticReviewsPath;
+
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+ private readonly IFileSystem _fileSystem;
+ private readonly IEnvironmentInfo _environmentInfo;
+ private readonly ITimerFactory _timerFactory;
+ private ITimer _shrinkMemoryTimer;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SqliteItemRepository"/> class.
+ /// </summary>
+ public SqliteItemRepository(IServerConfigurationManager config, IJsonSerializer jsonSerializer, ILogger logger, IMemoryStreamFactory memoryStreamProvider, IAssemblyInfo assemblyInfo, IFileSystem fileSystem, IEnvironmentInfo environmentInfo, ITimerFactory timerFactory)
+ : base(logger)
+ {
+ if (config == null)
+ {
+ throw new ArgumentNullException("config");
+ }
+ if (jsonSerializer == null)
+ {
+ throw new ArgumentNullException("jsonSerializer");
+ }
+
+ _config = config;
+ _jsonSerializer = jsonSerializer;
+ _memoryStreamProvider = memoryStreamProvider;
+ _fileSystem = fileSystem;
+ _environmentInfo = environmentInfo;
+ _timerFactory = timerFactory;
+ _typeMapper = new TypeMapper(assemblyInfo);
+
+ _criticReviewsPath = Path.Combine(_config.ApplicationPaths.DataPath, "critic-reviews");
+ DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db");
+ }
+
+ private const string ChaptersTableName = "Chapters2";
+
+ protected override int? CacheSize
+ {
+ get
+ {
+ return 20000;
+ }
+ }
+
+ protected override bool EnableTempStoreMemory
+ {
+ get
+ {
+ return true;
+ }
+ }
+
+ protected override void CloseConnection()
+ {
+ base.CloseConnection();
+
+ if (_shrinkMemoryTimer != null)
+ {
+ _shrinkMemoryTimer.Dispose();
+ _shrinkMemoryTimer = null;
+ }
+ }
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public async Task Initialize(SqliteUserDataRepository userDataRepo)
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ var createMediaStreamsTableCommand
+ = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, PRIMARY KEY (ItemId, StreamIndex))";
+
+ string[] queries = {
+ "PRAGMA locking_mode=NORMAL",
+
+ "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)",
+
+ "create table if not exists AncestorIds (ItemId GUID, AncestorId GUID, AncestorIdText TEXT, PRIMARY KEY (ItemId, AncestorId))",
+ "create index if not exists idx_AncestorIds1 on AncestorIds(AncestorId)",
+ "create index if not exists idx_AncestorIds2 on AncestorIds(AncestorIdText)",
+
+ "create table if not exists ItemValues (ItemId GUID, Type INT, Value TEXT, CleanValue TEXT)",
+
+ "create table if not exists People (ItemId GUID, Name TEXT NOT NULL, Role TEXT, PersonType TEXT, SortOrder int, ListOrder int)",
+
+ "drop index if exists idxPeopleItemId",
+ "create index if not exists idxPeopleItemId1 on People(ItemId,ListOrder)",
+ "create index if not exists idxPeopleName on People(Name)",
+
+ "create table if not exists "+ChaptersTableName+" (ItemId GUID, ChapterIndex INT, StartPositionTicks BIGINT, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))",
+
+ createMediaStreamsTableCommand,
+
+ "create index if not exists idx_mediastreams1 on mediastreams(ItemId)",
+
+ "pragma shrink_memory"
+
+ };
+
+ connection.RunQueries(queries);
+
+ connection.RunInTransaction(db =>
+ {
+ var existingColumnNames = GetColumnNames(db, "AncestorIds");
+ AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames);
+
+ existingColumnNames = GetColumnNames(db, "TypedBaseItems");
+
+ AddColumn(db, "TypedBaseItems", "Path", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ChannelId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsSports", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsKids", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "CustomRating", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Name", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "MediaType", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Overview", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ParentId", "GUID", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Genres", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SortName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "OfficialRatingDescription", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "HomePageUrl", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "VoteCount", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "DisplayMediaType", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "LocationType", "Text", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsLive", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsNews", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsPremiere", "BIT", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsHD", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ExternalEtag", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames);
+
+ AddColumn(db, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "LockedFields", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Studios", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Audio", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Tags", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "UnratedType", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "TopParentId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsItemByName", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SourceType", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "CriticRating", "Float", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "CriticRatingSummary", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "InheritedTags", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "CleanName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SlugName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeasonName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeriesSortName", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ShortOverview", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Tagline", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Keywords", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Images", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ThemeSongIds", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ThemeVideoIds", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ExtraType", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "Artists", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "ExternalId", "Text", existingColumnNames);
+ AddColumn(db, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames);
+
+ existingColumnNames = GetColumnNames(db, "ItemValues");
+ AddColumn(db, "ItemValues", "CleanValue", "Text", existingColumnNames);
+
+ existingColumnNames = GetColumnNames(db, ChaptersTableName);
+ AddColumn(db, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames);
+
+ existingColumnNames = GetColumnNames(db, "MediaStreams");
+ AddColumn(db, "MediaStreams", "IsAvc", "BIT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "TimeBase", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "Title", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "Comment", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "CodecTag", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "BitDepth", "INT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "RefFrames", "INT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames);
+ AddColumn(db, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames);
+ }, TransactionMode);
+
+ string[] postQueries =
+
+ {
+ // obsolete
+ "drop index if exists idx_TypedBaseItems",
+ "drop index if exists idx_mediastreams",
+ "drop index if exists idx_"+ChaptersTableName,
+ "drop index if exists idx_UserDataKeys1",
+ "drop index if exists idx_UserDataKeys2",
+ "drop index if exists idx_TypeTopParentId3",
+ "drop index if exists idx_TypeTopParentId2",
+ "drop index if exists idx_TypeTopParentId4",
+ "drop index if exists idx_Type",
+ "drop index if exists idx_TypeTopParentId",
+ "drop index if exists idx_GuidType",
+ "drop index if exists idx_TopParentId",
+ "drop index if exists idx_TypeTopParentId6",
+ "drop index if exists idx_ItemValues2",
+ "drop index if exists Idx_ProviderIds",
+ "drop index if exists idx_ItemValues3",
+ "drop index if exists idx_ItemValues4",
+ "drop index if exists idx_ItemValues5",
+ "drop index if exists idx_UserDataKeys3",
+ "drop table if exists UserDataKeys",
+ "drop table if exists ProviderIds",
+ "drop index if exists Idx_ProviderIds1",
+ "drop table if exists Images",
+ "drop index if exists idx_Images",
+ "drop index if exists idx_TypeSeriesPresentationUniqueKey",
+ "drop index if exists idx_SeriesPresentationUniqueKey",
+ "drop index if exists idx_TypeSeriesPresentationUniqueKey2",
+
+ "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)",
+ "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)",
+
+ "create index if not exists idx_PresentationUniqueKey on TypedBaseItems(PresentationUniqueKey)",
+ "create index if not exists idx_GuidTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,Type,IsFolder,IsVirtualItem)",
+ //"create index if not exists idx_GuidMediaTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,MediaType,IsFolder,IsVirtualItem)",
+ "create index if not exists idx_CleanNameType on TypedBaseItems(CleanName,Type)",
+
+ // covering index
+ "create index if not exists idx_TopParentIdGuid on TypedBaseItems(TopParentId,Guid)",
+
+ // series
+ "create index if not exists idx_TypeSeriesPresentationUniqueKey1 on TypedBaseItems(Type,SeriesPresentationUniqueKey,PresentationUniqueKey,SortName)",
+
+ // series counts
+ // seriesdateplayed sort order
+ "create index if not exists idx_TypeSeriesPresentationUniqueKey3 on TypedBaseItems(SeriesPresentationUniqueKey,Type,IsFolder,IsVirtualItem)",
+
+ // live tv programs
+ "create index if not exists idx_TypeTopParentIdStartDate on TypedBaseItems(Type,TopParentId,StartDate)",
+
+ // covering index for getitemvalues
+ "create index if not exists idx_TypeTopParentIdGuid on TypedBaseItems(Type,TopParentId,Guid)",
+
+ // used by movie suggestions
+ "create index if not exists idx_TypeTopParentIdGroup on TypedBaseItems(Type,TopParentId,PresentationUniqueKey)",
+ "create index if not exists idx_TypeTopParentId5 on TypedBaseItems(TopParentId,IsVirtualItem)",
+
+ // latest items
+ "create index if not exists idx_TypeTopParentId9 on TypedBaseItems(TopParentId,Type,IsVirtualItem,PresentationUniqueKey,DateCreated)",
+ "create index if not exists idx_TypeTopParentId8 on TypedBaseItems(TopParentId,IsFolder,IsVirtualItem,PresentationUniqueKey,DateCreated)",
+
+ // resume
+ "create index if not exists idx_TypeTopParentId7 on TypedBaseItems(TopParentId,MediaType,IsVirtualItem,PresentationUniqueKey)",
+
+ // items by name
+ "create index if not exists idx_ItemValues6 on ItemValues(ItemId,Type,CleanValue)",
+ "create index if not exists idx_ItemValues7 on ItemValues(Type,CleanValue,ItemId)"
+ };
+
+ connection.RunQueries(postQueries);
+
+ //await Vacuum(_connection).ConfigureAwait(false);
+ }
+
+ userDataRepo.Initialize(WriteLock, _connection);
+
+ _shrinkMemoryTimer = _timerFactory.Create(OnShrinkMemoryTimerCallback, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(15));
+ }
+
+ private void OnShrinkMemoryTimerCallback(object state)
+ {
+ try
+ {
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunQueries(new string[]
+ {
+ "pragma shrink_memory"
+ });
+ }
+ }
+
+ GC.Collect();
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error running shrink memory", ex);
+ }
+ }
+
+ private readonly string[] _retriveItemColumns =
+ {
+ "type",
+ "data",
+ "StartDate",
+ "EndDate",
+ "ChannelId",
+ "IsMovie",
+ "IsSports",
+ "IsKids",
+ "IsSeries",
+ "IsLive",
+ "IsNews",
+ "IsPremiere",
+ "EpisodeTitle",
+ "IsRepeat",
+ "CommunityRating",
+ "CustomRating",
+ "IndexNumber",
+ "IsLocked",
+ "PreferredMetadataLanguage",
+ "PreferredMetadataCountryCode",
+ "IsHD",
+ "ExternalEtag",
+ "DateLastRefreshed",
+ "Name",
+ "Path",
+ "PremiereDate",
+ "Overview",
+ "ParentIndexNumber",
+ "ProductionYear",
+ "OfficialRating",
+ "OfficialRatingDescription",
+ "HomePageUrl",
+ "DisplayMediaType",
+ "ForcedSortName",
+ "RunTimeTicks",
+ "VoteCount",
+ "DateCreated",
+ "DateModified",
+ "guid",
+ "Genres",
+ "ParentId",
+ "Audio",
+ "ExternalServiceId",
+ "IsInMixedFolder",
+ "DateLastSaved",
+ "LockedFields",
+ "Studios",
+ "Tags",
+ "SourceType",
+ "TrailerTypes",
+ "OriginalTitle",
+ "PrimaryVersionId",
+ "DateLastMediaAdded",
+ "Album",
+ "CriticRating",
+ "CriticRatingSummary",
+ "IsVirtualItem",
+ "SeriesName",
+ "SeasonName",
+ "SeasonId",
+ "SeriesId",
+ "SeriesSortName",
+ "PresentationUniqueKey",
+ "InheritedParentalRatingValue",
+ "InheritedTags",
+ "ExternalSeriesId",
+ "ShortOverview",
+ "Tagline",
+ "Keywords",
+ "ProviderIds",
+ "Images",
+ "ProductionLocations",
+ "ThemeSongIds",
+ "ThemeVideoIds",
+ "TotalBitrate",
+ "ExtraType",
+ "Artists",
+ "AlbumArtists",
+ "ExternalId",
+ "SeriesPresentationUniqueKey"
+ };
+
+ private readonly string[] _mediaStreamSaveColumns =
+ {
+ "ItemId",
+ "StreamIndex",
+ "StreamType",
+ "Codec",
+ "Language",
+ "ChannelLayout",
+ "Profile",
+ "AspectRatio",
+ "Path",
+ "IsInterlaced",
+ "BitRate",
+ "Channels",
+ "SampleRate",
+ "IsDefault",
+ "IsForced",
+ "IsExternal",
+ "Height",
+ "Width",
+ "AverageFrameRate",
+ "RealFrameRate",
+ "Level",
+ "PixelFormat",
+ "BitDepth",
+ "IsAnamorphic",
+ "RefFrames",
+ "CodecTag",
+ "Comment",
+ "NalLengthSize",
+ "IsAvc",
+ "Title",
+ "TimeBase",
+ "CodecTimeBase"
+ };
+
+ private string GetSaveItemCommandText()
+ {
+ var saveColumns = new List<string>
+ {
+ "guid",
+ "type",
+ "data",
+ "Path",
+ "StartDate",
+ "EndDate",
+ "ChannelId",
+ "IsKids",
+ "IsMovie",
+ "IsSports",
+ "IsSeries",
+ "IsLive",
+ "IsNews",
+ "IsPremiere",
+ "EpisodeTitle",
+ "IsRepeat",
+ "CommunityRating",
+ "CustomRating",
+ "IndexNumber",
+ "IsLocked",
+ "Name",
+ "OfficialRating",
+ "MediaType",
+ "Overview",
+ "ParentIndexNumber",
+ "PremiereDate",
+ "ProductionYear",
+ "ParentId",
+ "Genres",
+ "InheritedParentalRatingValue",
+ "SortName",
+ "RunTimeTicks",
+ "OfficialRatingDescription",
+ "HomePageUrl",
+ "VoteCount",
+ "DisplayMediaType",
+ "DateCreated",
+ "DateModified",
+ "ForcedSortName",
+ "LocationType",
+ "PreferredMetadataLanguage",
+ "PreferredMetadataCountryCode",
+ "IsHD",
+ "ExternalEtag",
+ "DateLastRefreshed",
+ "DateLastSaved",
+ "IsInMixedFolder",
+ "LockedFields",
+ "Studios",
+ "Audio",
+ "ExternalServiceId",
+ "Tags",
+ "IsFolder",
+ "UnratedType",
+ "TopParentId",
+ "IsItemByName",
+ "SourceType",
+ "TrailerTypes",
+ "CriticRating",
+ "CriticRatingSummary",
+ "InheritedTags",
+ "CleanName",
+ "PresentationUniqueKey",
+ "SlugName",
+ "OriginalTitle",
+ "PrimaryVersionId",
+ "DateLastMediaAdded",
+ "Album",
+ "IsVirtualItem",
+ "SeriesName",
+ "UserDataKey",
+ "SeasonName",
+ "SeasonId",
+ "SeriesId",
+ "SeriesSortName",
+ "ExternalSeriesId",
+ "ShortOverview",
+ "Tagline",
+ "Keywords",
+ "ProviderIds",
+ "Images",
+ "ProductionLocations",
+ "ThemeSongIds",
+ "ThemeVideoIds",
+ "TotalBitrate",
+ "ExtraType",
+ "Artists",
+ "AlbumArtists",
+ "ExternalId",
+ "SeriesPresentationUniqueKey"
+ };
+
+ var saveItemCommandCommandText = "replace into TypedBaseItems (" + string.Join(",", saveColumns.ToArray()) + ") values (";
+
+ for (var i = 0; i < saveColumns.Count; i++)
+ {
+ if (i > 0)
+ {
+ saveItemCommandCommandText += ",";
+ }
+ saveItemCommandCommandText += "@" + saveColumns[i];
+ }
+ saveItemCommandCommandText += ")";
+ return saveItemCommandCommandText;
+ }
+
+ /// <summary>
+ /// Save a standard item in the repo
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public Task SaveItem(BaseItem item, CancellationToken cancellationToken)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ return SaveItems(new List<BaseItem> { item }, cancellationToken);
+ }
+
+ /// <summary>
+ /// Saves the items.
+ /// </summary>
+ /// <param name="items">The items.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">
+ /// items
+ /// or
+ /// cancellationToken
+ /// </exception>
+ public async Task SaveItems(List<BaseItem> items, CancellationToken cancellationToken)
+ {
+ if (items == null)
+ {
+ throw new ArgumentNullException("items");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ CheckDisposed();
+
+ var tuples = new List<Tuple<BaseItem, List<Guid>, BaseItem, string>>();
+ foreach (var item in items)
+ {
+ var ancestorIds = item.SupportsAncestors ?
+ item.GetAncestorIds().Distinct().ToList() :
+ null;
+
+ var topParent = item.GetTopParent();
+
+ var userdataKey = item.GetUserDataKeys().FirstOrDefault();
+
+ tuples.Add(new Tuple<BaseItem, List<Guid>, BaseItem, string>(item, ancestorIds, topParent, userdataKey));
+ }
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ SaveItemsInTranscation(db, tuples);
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private void SaveItemsInTranscation(IDatabaseConnection db, List<Tuple<BaseItem, List<Guid>, BaseItem, string>> tuples)
+ {
+ var requiresReset = false;
+
+ var statements = PrepareAllSafe(db, new string[]
+ {
+ GetSaveItemCommandText(),
+ "delete from AncestorIds where ItemId=@ItemId",
+ "insert into AncestorIds (ItemId, AncestorId, AncestorIdText) values (@ItemId, @AncestorId, @AncestorIdText)"
+ }).ToList();
+
+ using (var saveItemStatement = statements[0])
+ {
+ using (var deleteAncestorsStatement = statements[1])
+ {
+ using (var updateAncestorsStatement = statements[2])
+ {
+ foreach (var tuple in tuples)
+ {
+ if (requiresReset)
+ {
+ saveItemStatement.Reset();
+ }
+
+ var item = tuple.Item1;
+ var topParent = tuple.Item3;
+ var userDataKey = tuple.Item4;
+
+ SaveItem(item, topParent, userDataKey, saveItemStatement);
+ //Logger.Debug(_saveItemCommand.CommandText);
+
+ if (item.SupportsAncestors)
+ {
+ UpdateAncestors(item.Id, tuple.Item2, db, deleteAncestorsStatement, updateAncestorsStatement);
+ }
+
+ UpdateItemValues(item.Id, GetItemValuesToSave(item), db);
+
+ requiresReset = true;
+ }
+ }
+ }
+ }
+ }
+
+ private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, IStatement saveItemStatement)
+ {
+ saveItemStatement.TryBind("@guid", item.Id);
+ saveItemStatement.TryBind("@type", item.GetType().FullName);
+
+ if (TypeRequiresDeserialization(item.GetType()))
+ {
+ saveItemStatement.TryBind("@data", _jsonSerializer.SerializeToBytes(item, _memoryStreamProvider));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@data");
+ }
+
+ saveItemStatement.TryBind("@Path", item.Path);
+
+ var hasStartDate = item as IHasStartDate;
+ if (hasStartDate != null)
+ {
+ saveItemStatement.TryBind("@StartDate", hasStartDate.StartDate);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@StartDate");
+ }
+
+ if (item.EndDate.HasValue)
+ {
+ saveItemStatement.TryBind("@EndDate", item.EndDate.Value);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@EndDate");
+ }
+
+ saveItemStatement.TryBind("@ChannelId", item.ChannelId);
+
+ var hasProgramAttributes = item as IHasProgramAttributes;
+ if (hasProgramAttributes != null)
+ {
+ saveItemStatement.TryBind("@IsKids", hasProgramAttributes.IsKids);
+ saveItemStatement.TryBind("@IsMovie", hasProgramAttributes.IsMovie);
+ saveItemStatement.TryBind("@IsSports", hasProgramAttributes.IsSports);
+ saveItemStatement.TryBind("@IsSeries", hasProgramAttributes.IsSeries);
+ saveItemStatement.TryBind("@IsLive", hasProgramAttributes.IsLive);
+ saveItemStatement.TryBind("@IsNews", hasProgramAttributes.IsNews);
+ saveItemStatement.TryBind("@IsPremiere", hasProgramAttributes.IsPremiere);
+ saveItemStatement.TryBind("@EpisodeTitle", hasProgramAttributes.EpisodeTitle);
+ saveItemStatement.TryBind("@IsRepeat", hasProgramAttributes.IsRepeat);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@IsKids");
+ saveItemStatement.TryBindNull("@IsMovie");
+ saveItemStatement.TryBindNull("@IsSports");
+ saveItemStatement.TryBindNull("@IsSeries");
+ saveItemStatement.TryBindNull("@IsLive");
+ saveItemStatement.TryBindNull("@IsNews");
+ saveItemStatement.TryBindNull("@IsPremiere");
+ saveItemStatement.TryBindNull("@EpisodeTitle");
+ saveItemStatement.TryBindNull("@IsRepeat");
+ }
+
+ saveItemStatement.TryBind("@CommunityRating", item.CommunityRating);
+ saveItemStatement.TryBind("@CustomRating", item.CustomRating);
+ saveItemStatement.TryBind("@IndexNumber", item.IndexNumber);
+ saveItemStatement.TryBind("@IsLocked", item.IsLocked);
+ saveItemStatement.TryBind("@Name", item.Name);
+ saveItemStatement.TryBind("@OfficialRating", item.OfficialRating);
+ saveItemStatement.TryBind("@MediaType", item.MediaType);
+ saveItemStatement.TryBind("@Overview", item.Overview);
+ saveItemStatement.TryBind("@ParentIndexNumber", item.ParentIndexNumber);
+ saveItemStatement.TryBind("@PremiereDate", item.PremiereDate);
+ saveItemStatement.TryBind("@ProductionYear", item.ProductionYear);
+
+ if (item.ParentId == Guid.Empty)
+ {
+ saveItemStatement.TryBindNull("@ParentId");
+ }
+ else
+ {
+ saveItemStatement.TryBind("@ParentId", item.ParentId);
+ }
+
+ if (item.Genres.Count > 0)
+ {
+ saveItemStatement.TryBind("@Genres", string.Join("|", item.Genres.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@Genres");
+ }
+
+ saveItemStatement.TryBind("@InheritedParentalRatingValue", item.InheritedParentalRatingValue);
+
+ saveItemStatement.TryBind("@SortName", item.SortName);
+ saveItemStatement.TryBind("@RunTimeTicks", item.RunTimeTicks);
+
+ saveItemStatement.TryBind("@OfficialRatingDescription", item.OfficialRatingDescription);
+ saveItemStatement.TryBind("@HomePageUrl", item.HomePageUrl);
+ saveItemStatement.TryBind("@VoteCount", item.VoteCount);
+ saveItemStatement.TryBind("@DisplayMediaType", item.DisplayMediaType);
+ saveItemStatement.TryBind("@DateCreated", item.DateCreated);
+ saveItemStatement.TryBind("@DateModified", item.DateModified);
+
+ saveItemStatement.TryBind("@ForcedSortName", item.ForcedSortName);
+ saveItemStatement.TryBind("@LocationType", item.LocationType.ToString());
+
+ saveItemStatement.TryBind("@PreferredMetadataLanguage", item.PreferredMetadataLanguage);
+ saveItemStatement.TryBind("@PreferredMetadataCountryCode", item.PreferredMetadataCountryCode);
+ saveItemStatement.TryBind("@IsHD", item.IsHD);
+ saveItemStatement.TryBind("@ExternalEtag", item.ExternalEtag);
+
+ if (item.DateLastRefreshed != default(DateTime))
+ {
+ saveItemStatement.TryBind("@DateLastRefreshed", item.DateLastRefreshed);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@DateLastRefreshed");
+ }
+
+ if (item.DateLastSaved != default(DateTime))
+ {
+ saveItemStatement.TryBind("@DateLastSaved", item.DateLastSaved);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@DateLastSaved");
+ }
+
+ saveItemStatement.TryBind("@IsInMixedFolder", item.IsInMixedFolder);
+
+ if (item.LockedFields.Count > 0)
+ {
+ saveItemStatement.TryBind("@LockedFields", string.Join("|", item.LockedFields.Select(i => i.ToString()).ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@LockedFields");
+ }
+
+ if (item.Studios.Count > 0)
+ {
+ saveItemStatement.TryBind("@Studios", string.Join("|", item.Studios.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@Studios");
+ }
+
+ if (item.Audio.HasValue)
+ {
+ saveItemStatement.TryBind("@Audio", item.Audio.Value.ToString());
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@Audio");
+ }
+
+ saveItemStatement.TryBind("@ExternalServiceId", item.ServiceName);
+
+ if (item.Tags.Count > 0)
+ {
+ saveItemStatement.TryBind("@Tags", string.Join("|", item.Tags.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@Tags");
+ }
+
+ saveItemStatement.TryBind("@IsFolder", item.IsFolder);
+
+ saveItemStatement.TryBind("@UnratedType", item.GetBlockUnratedType().ToString());
+
+ if (topParent != null)
+ {
+ //Logger.Debug("Item {0} has top parent {1}", item.Id, topParent.Id);
+ saveItemStatement.TryBind("@TopParentId", topParent.Id.ToString("N"));
+ }
+ else
+ {
+ //Logger.Debug("Item {0} has null top parent", item.Id);
+ saveItemStatement.TryBindNull("@TopParentId");
+ }
+
+ var isByName = false;
+ var byName = item as IItemByName;
+ if (byName != null)
+ {
+ var dualAccess = item as IHasDualAccess;
+ isByName = dualAccess == null || dualAccess.IsAccessedByName;
+ }
+ saveItemStatement.TryBind("@IsItemByName", isByName);
+ saveItemStatement.TryBind("@SourceType", item.SourceType.ToString());
+
+ var trailer = item as Trailer;
+ if (trailer != null && trailer.TrailerTypes.Count > 0)
+ {
+ saveItemStatement.TryBind("@TrailerTypes", string.Join("|", trailer.TrailerTypes.Select(i => i.ToString()).ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@TrailerTypes");
+ }
+
+ saveItemStatement.TryBind("@CriticRating", item.CriticRating);
+ saveItemStatement.TryBind("@CriticRatingSummary", item.CriticRatingSummary);
+
+ var inheritedTags = item.InheritedTags;
+ if (inheritedTags.Count > 0)
+ {
+ saveItemStatement.TryBind("@InheritedTags", string.Join("|", inheritedTags.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@InheritedTags");
+ }
+
+ if (string.IsNullOrWhiteSpace(item.Name))
+ {
+ saveItemStatement.TryBindNull("@CleanName");
+ }
+ else
+ {
+ saveItemStatement.TryBind("@CleanName", GetCleanValue(item.Name));
+ }
+
+ saveItemStatement.TryBind("@PresentationUniqueKey", item.PresentationUniqueKey);
+ saveItemStatement.TryBind("@SlugName", item.SlugName);
+ saveItemStatement.TryBind("@OriginalTitle", item.OriginalTitle);
+
+ var video = item as Video;
+ if (video != null)
+ {
+ saveItemStatement.TryBind("@PrimaryVersionId", video.PrimaryVersionId);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@PrimaryVersionId");
+ }
+
+ var folder = item as Folder;
+ if (folder != null && folder.DateLastMediaAdded.HasValue)
+ {
+ saveItemStatement.TryBind("@DateLastMediaAdded", folder.DateLastMediaAdded.Value);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@DateLastMediaAdded");
+ }
+
+ saveItemStatement.TryBind("@Album", item.Album);
+ saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem);
+
+ var hasSeries = item as IHasSeries;
+ if (hasSeries != null)
+ {
+ saveItemStatement.TryBind("@SeriesName", hasSeries.SeriesName);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@SeriesName");
+ }
+
+ if (string.IsNullOrWhiteSpace(userDataKey))
+ {
+ saveItemStatement.TryBindNull("@UserDataKey");
+ }
+ else
+ {
+ saveItemStatement.TryBind("@UserDataKey", userDataKey);
+ }
+
+ var episode = item as Episode;
+ if (episode != null)
+ {
+ saveItemStatement.TryBind("@SeasonName", episode.SeasonName);
+ saveItemStatement.TryBind("@SeasonId", episode.SeasonId);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@SeasonName");
+ saveItemStatement.TryBindNull("@SeasonId");
+ }
+
+ if (hasSeries != null)
+ {
+ saveItemStatement.TryBind("@SeriesId", hasSeries.SeriesId);
+ saveItemStatement.TryBind("@SeriesSortName", hasSeries.SeriesSortName);
+ saveItemStatement.TryBind("@SeriesPresentationUniqueKey", hasSeries.SeriesPresentationUniqueKey);
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@SeriesId");
+ saveItemStatement.TryBindNull("@SeriesSortName");
+ saveItemStatement.TryBindNull("@SeriesPresentationUniqueKey");
+ }
+
+ saveItemStatement.TryBind("@ExternalSeriesId", item.ExternalSeriesId);
+ saveItemStatement.TryBind("@ShortOverview", item.ShortOverview);
+ saveItemStatement.TryBind("@Tagline", item.Tagline);
+
+ if (item.Keywords.Count > 0)
+ {
+ saveItemStatement.TryBind("@Keywords", string.Join("|", item.Keywords.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@Keywords");
+ }
+
+ saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item));
+ saveItemStatement.TryBind("@Images", SerializeImages(item));
+
+ if (item.ProductionLocations.Count > 0)
+ {
+ saveItemStatement.TryBind("@ProductionLocations", string.Join("|", item.ProductionLocations.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@ProductionLocations");
+ }
+
+ if (item.ThemeSongIds.Count > 0)
+ {
+ saveItemStatement.TryBind("@ThemeSongIds", string.Join("|", item.ThemeSongIds.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@ThemeSongIds");
+ }
+
+ if (item.ThemeVideoIds.Count > 0)
+ {
+ saveItemStatement.TryBind("@ThemeVideoIds", string.Join("|", item.ThemeVideoIds.ToArray()));
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@ThemeVideoIds");
+ }
+
+ saveItemStatement.TryBind("@TotalBitrate", item.TotalBitrate);
+ if (item.ExtraType.HasValue)
+ {
+ saveItemStatement.TryBind("@ExtraType", item.ExtraType.Value.ToString());
+ }
+ else
+ {
+ saveItemStatement.TryBindNull("@ExtraType");
+ }
+
+ string artists = null;
+ var hasArtists = item as IHasArtist;
+ if (hasArtists != null)
+ {
+ if (hasArtists.Artists.Count > 0)
+ {
+ artists = string.Join("|", hasArtists.Artists.ToArray());
+ }
+ }
+ saveItemStatement.TryBind("@Artists", artists);
+
+ string albumArtists = null;
+ var hasAlbumArtists = item as IHasAlbumArtist;
+ if (hasAlbumArtists != null)
+ {
+ if (hasAlbumArtists.AlbumArtists.Count > 0)
+ {
+ albumArtists = string.Join("|", hasAlbumArtists.AlbumArtists.ToArray());
+ }
+ }
+ saveItemStatement.TryBind("@AlbumArtists", albumArtists);
+ saveItemStatement.TryBind("@ExternalId", item.ExternalId);
+
+ saveItemStatement.MoveNext();
+ }
+
+ private string SerializeProviderIds(BaseItem item)
+ {
+ // Ideally we shouldn't need this IsNullOrWhiteSpace check but we're seeing some cases of bad data slip through
+ var ids = item.ProviderIds
+ .Where(i => !string.IsNullOrWhiteSpace(i.Value))
+ .ToList();
+
+ if (ids.Count == 0)
+ {
+ return null;
+ }
+
+ return string.Join("|", ids.Select(i => i.Key + "=" + i.Value).ToArray());
+ }
+
+ private void DeserializeProviderIds(string value, BaseItem item)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return;
+ }
+
+ if (item.ProviderIds.Count > 0)
+ {
+ return;
+ }
+
+ var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var part in parts)
+ {
+ var idParts = part.Split('=');
+
+ if (idParts.Length == 2)
+ {
+ item.SetProviderId(idParts[0], idParts[1]);
+ }
+ }
+ }
+
+ private string SerializeImages(BaseItem item)
+ {
+ var images = item.ImageInfos.ToList();
+
+ if (images.Count == 0)
+ {
+ return null;
+ }
+
+ var imageStrings = images.Where(i => !string.IsNullOrWhiteSpace(i.Path)).Select(ToValueString).ToArray();
+
+ return string.Join("|", imageStrings);
+ }
+
+ private void DeserializeImages(string value, BaseItem item)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return;
+ }
+
+ if (item.ImageInfos.Count > 0)
+ {
+ return;
+ }
+
+ var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var part in parts)
+ {
+ var image = ItemImageInfoFromValueString(part);
+
+ if (image != null)
+ {
+ item.ImageInfos.Add(image);
+ }
+ }
+ }
+
+ public string ToValueString(ItemImageInfo image)
+ {
+ var delimeter = "*";
+
+ var path = image.Path;
+
+ if (path == null)
+ {
+ path = string.Empty;
+ }
+
+ return path +
+ delimeter +
+ image.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) +
+ delimeter +
+ image.Type +
+ delimeter +
+ image.IsPlaceholder;
+ }
+
+ public ItemImageInfo ItemImageInfoFromValueString(string value)
+ {
+ var parts = value.Split(new[] { '*' }, StringSplitOptions.None);
+
+ if (parts.Length != 4)
+ {
+ return null;
+ }
+
+ var image = new ItemImageInfo();
+
+ image.Path = parts[0];
+ image.DateModified = new DateTime(long.Parse(parts[1], CultureInfo.InvariantCulture), DateTimeKind.Utc);
+ image.Type = (ImageType)Enum.Parse(typeof(ImageType), parts[2], true);
+ image.IsPlaceholder = string.Equals(parts[3], true.ToString(), StringComparison.OrdinalIgnoreCase);
+
+ return image;
+ }
+
+ /// <summary>
+ /// Internal retrieve from items or users table
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <returns>BaseItem.</returns>
+ /// <exception cref="System.ArgumentNullException">id</exception>
+ /// <exception cref="System.ArgumentException"></exception>
+ public BaseItem RetrieveItem(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ CheckDisposed();
+ //Logger.Info("Retrieving item {0}", id.ToString("N"));
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = PrepareStatementSafe(connection, "select " + string.Join(",", _retriveItemColumns) + " from TypedBaseItems where guid = @guid"))
+ {
+ statement.TryBind("@guid", id);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return GetItem(row);
+ }
+ }
+
+ return null;
+ }
+ }
+ }
+
+ private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader)
+ {
+ return GetItem(reader, new InternalItemsQuery());
+ }
+
+ private bool TypeRequiresDeserialization(Type type)
+ {
+ if (_config.Configuration.SkipDeserializationForBasicTypes)
+ {
+ if (type == typeof(MusicGenre))
+ {
+ return false;
+ }
+ if (type == typeof(GameGenre))
+ {
+ return false;
+ }
+ if (type == typeof(Genre))
+ {
+ return false;
+ }
+ if (type == typeof(Studio))
+ {
+ return false;
+ }
+ if (type == typeof(Year))
+ {
+ return false;
+ }
+ if (type == typeof(Book))
+ {
+ return false;
+ }
+ if (type == typeof(Person))
+ {
+ return false;
+ }
+ if (type == typeof(RecordingGroup))
+ {
+ return false;
+ }
+ if (type == typeof(Channel))
+ {
+ return false;
+ }
+ if (type == typeof(ManualCollectionsFolder))
+ {
+ return false;
+ }
+ if (type == typeof(CameraUploadsFolder))
+ {
+ return false;
+ }
+ if (type == typeof(PlaylistsFolder))
+ {
+ return false;
+ }
+ if (type == typeof(UserRootFolder))
+ {
+ return false;
+ }
+ if (type == typeof(PhotoAlbum))
+ {
+ return false;
+ }
+ if (type == typeof(Season))
+ {
+ return false;
+ }
+ if (type == typeof(MusicArtist))
+ {
+ return false;
+ }
+ }
+ if (_config.Configuration.SkipDeserializationForPrograms)
+ {
+ if (type == typeof(LiveTvProgram))
+ {
+ return false;
+ }
+ }
+ if (_config.Configuration.SkipDeserializationForAudio)
+ {
+ if (type == typeof(Audio))
+ {
+ return false;
+ }
+ if (type == typeof(LiveTvAudioRecording))
+ {
+ return false;
+ }
+ if (type == typeof(AudioPodcast))
+ {
+ return false;
+ }
+ if (type == typeof(AudioBook))
+ {
+ return false;
+ }
+ if (type == typeof(MusicAlbum))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader, InternalItemsQuery query)
+ {
+ var typeString = reader.GetString(0);
+
+ var type = _typeMapper.GetType(typeString);
+
+ if (type == null)
+ {
+ //Logger.Debug("Unknown type {0}", typeString);
+
+ return null;
+ }
+
+ BaseItem item = null;
+
+ if (TypeRequiresDeserialization(type))
+ {
+ using (var stream = _memoryStreamProvider.CreateNew(reader[1].ToBlob()))
+ {
+ stream.Position = 0;
+
+ try
+ {
+ item = _jsonSerializer.DeserializeFromStream(stream, type) as BaseItem;
+ }
+ catch (SerializationException ex)
+ {
+ Logger.ErrorException("Error deserializing item", ex);
+ }
+ }
+ }
+
+ if (item == null)
+ {
+ try
+ {
+ item = Activator.CreateInstance(type) as BaseItem;
+ }
+ catch
+ {
+ }
+ }
+
+ if (item == null)
+ {
+ return null;
+ }
+
+ if (!reader.IsDBNull(2))
+ {
+ var hasStartDate = item as IHasStartDate;
+ if (hasStartDate != null)
+ {
+ hasStartDate.StartDate = reader[2].ReadDateTime();
+ }
+ }
+
+ if (!reader.IsDBNull(3))
+ {
+ item.EndDate = reader[3].ReadDateTime();
+ }
+
+ if (!reader.IsDBNull(4))
+ {
+ item.ChannelId = reader.GetString(4);
+ }
+
+ var index = 5;
+
+ var hasProgramAttributes = item as IHasProgramAttributes;
+ if (hasProgramAttributes != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsMovie = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsSports = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsKids = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsSeries = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsLive = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsNews = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsPremiere = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.EpisodeTitle = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ hasProgramAttributes.IsRepeat = reader.GetBoolean(index);
+ }
+ index++;
+ }
+ else
+ {
+ index += 9;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.CommunityRating = reader.GetFloat(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.CustomRating))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.CustomRating = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.IndexNumber = reader.GetInt32(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.Settings))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.IsLocked = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.PreferredMetadataLanguage = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.PreferredMetadataCountryCode = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.IsHD = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ExternalEtag = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.DateLastRefreshed = reader[index].ReadDateTime();
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.Name = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.Path = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.PremiereDate = reader[index].ReadDateTime();
+ }
+ index++;
+
+ if (query.HasField(ItemFields.Overview))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Overview = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ParentIndexNumber = reader.GetInt32(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ProductionYear = reader.GetInt32(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.OfficialRating = reader.GetString(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.OfficialRatingDescription))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.OfficialRatingDescription = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.HomePageUrl))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.HomePageUrl = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.DisplayMediaType))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.DisplayMediaType = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.SortName))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.ForcedSortName = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.RunTimeTicks = reader.GetInt64(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.VoteCount))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.VoteCount = reader.GetInt32(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.DateCreated))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.DateCreated = reader[index].ReadDateTime();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.DateModified = reader[index].ReadDateTime();
+ }
+ index++;
+
+ item.Id = reader.GetGuid(index);
+ index++;
+
+ if (query.HasField(ItemFields.Genres))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Genres = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ParentId = reader.GetGuid(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.Audio = (ProgramAudio)Enum.Parse(typeof(ProgramAudio), reader.GetString(index), true);
+ }
+ index++;
+
+ // TODO: Even if not needed by apps, the server needs it internally
+ // But get this excluded from contexts where it is not needed
+ if (!reader.IsDBNull(index))
+ {
+ item.ServiceName = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.IsInMixedFolder = reader.GetBoolean(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.DateLastSaved = reader[index].ReadDateTime();
+ }
+ index++;
+
+ if (query.HasField(ItemFields.Settings))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.LockedFields = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => (MetadataFields)Enum.Parse(typeof(MetadataFields), i, true)).ToList();
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.Studios))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Studios = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.Tags))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Tags = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.SourceType = (SourceType)Enum.Parse(typeof(SourceType), reader.GetString(index), true);
+ }
+ index++;
+
+ var trailer = item as Trailer;
+ if (trailer != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ trailer.TrailerTypes = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => (TrailerType)Enum.Parse(typeof(TrailerType), i, true)).ToList();
+ }
+ }
+ index++;
+
+ if (query.HasField(ItemFields.OriginalTitle))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.OriginalTitle = reader.GetString(index);
+ }
+ index++;
+ }
+
+ var video = item as Video;
+ if (video != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ video.PrimaryVersionId = reader.GetString(index);
+ }
+ }
+ index++;
+
+ if (query.HasField(ItemFields.DateLastMediaAdded))
+ {
+ var folder = item as Folder;
+ if (folder != null && !reader.IsDBNull(index))
+ {
+ folder.DateLastMediaAdded = reader[index].ReadDateTime();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.Album = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.CriticRating = reader.GetFloat(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.CriticRatingSummary))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.CriticRatingSummary = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.IsVirtualItem = reader.GetBoolean(index);
+ }
+ index++;
+
+ var hasSeries = item as IHasSeries;
+ if (hasSeries != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ hasSeries.SeriesName = reader.GetString(index);
+ }
+ }
+ index++;
+
+ var episode = item as Episode;
+ if (episode != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ episode.SeasonName = reader.GetString(index);
+ }
+ index++;
+ if (!reader.IsDBNull(index))
+ {
+ episode.SeasonId = reader.GetGuid(index);
+ }
+ }
+ else
+ {
+ index++;
+ }
+ index++;
+
+ if (hasSeries != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ hasSeries.SeriesId = reader.GetGuid(index);
+ }
+ }
+ index++;
+
+ if (hasSeries != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ hasSeries.SeriesSortName = reader.GetString(index);
+ }
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.PresentationUniqueKey = reader.GetString(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.InheritedParentalRatingValue = reader.GetInt32(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.InheritedTags = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ExternalSeriesId = reader.GetString(index);
+ }
+ index++;
+
+ if (query.HasField(ItemFields.ShortOverview))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.ShortOverview = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.Taglines))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Tagline = reader.GetString(index);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.Keywords))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.Keywords = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ DeserializeProviderIds(reader.GetString(index), item);
+ }
+ index++;
+
+ if (query.DtoOptions.EnableImages)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ DeserializeImages(reader.GetString(index), item);
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.ProductionLocations))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.ProductionLocations = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.ThemeSongIds))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.ThemeSongIds = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => new Guid(i)).ToList();
+ }
+ index++;
+ }
+
+ if (query.HasField(ItemFields.ThemeVideoIds))
+ {
+ if (!reader.IsDBNull(index))
+ {
+ item.ThemeVideoIds = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).Select(i => new Guid(i)).ToList();
+ }
+ index++;
+ }
+
+ if (!reader.IsDBNull(index))
+ {
+ item.TotalBitrate = reader.GetInt32(index);
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ExtraType = (ExtraType)Enum.Parse(typeof(ExtraType), reader.GetString(index), true);
+ }
+ index++;
+
+ var hasArtists = item as IHasArtist;
+ if (hasArtists != null && !reader.IsDBNull(index))
+ {
+ hasArtists.Artists = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+
+ var hasAlbumArtists = item as IHasAlbumArtist;
+ if (hasAlbumArtists != null && !reader.IsDBNull(index))
+ {
+ hasAlbumArtists.AlbumArtists = reader.GetString(index).Split('|').Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
+ }
+ index++;
+
+ if (!reader.IsDBNull(index))
+ {
+ item.ExternalId = reader.GetString(index);
+ }
+ index++;
+
+ if (hasSeries != null)
+ {
+ if (!reader.IsDBNull(index))
+ {
+ hasSeries.SeriesPresentationUniqueKey = reader.GetString(index);
+ }
+ }
+ index++;
+
+ if (string.IsNullOrWhiteSpace(item.Tagline))
+ {
+ var movie = item as Movie;
+ if (movie != null && movie.Taglines.Count > 0)
+ {
+ movie.Tagline = movie.Taglines[0];
+ }
+ }
+
+ if (type == typeof(Person) && item.ProductionLocations.Count == 0)
+ {
+ var person = (Person)item;
+ if (!string.IsNullOrWhiteSpace(person.PlaceOfBirth))
+ {
+ item.ProductionLocations = new List<string> { person.PlaceOfBirth };
+ }
+ }
+
+ return item;
+ }
+
+ /// <summary>
+ /// Gets the critic reviews.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <returns>Task{IEnumerable{ItemReview}}.</returns>
+ public IEnumerable<ItemReview> GetCriticReviews(Guid itemId)
+ {
+ try
+ {
+ var path = Path.Combine(_criticReviewsPath, itemId + ".json");
+
+ return _jsonSerializer.DeserializeFromFile<List<ItemReview>>(path);
+ }
+ catch (FileNotFoundException)
+ {
+ return new List<ItemReview>();
+ }
+ catch (IOException)
+ {
+ return new List<ItemReview>();
+ }
+ }
+
+ private readonly Task _cachedTask = Task.FromResult(true);
+ /// <summary>
+ /// Saves the critic reviews.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="criticReviews">The critic reviews.</param>
+ /// <returns>Task.</returns>
+ public Task SaveCriticReviews(Guid itemId, IEnumerable<ItemReview> criticReviews)
+ {
+ _fileSystem.CreateDirectory(_criticReviewsPath);
+
+ var path = Path.Combine(_criticReviewsPath, itemId + ".json");
+
+ _jsonSerializer.SerializeToFile(criticReviews.ToList(), path);
+
+ return _cachedTask;
+ }
+
+ /// <summary>
+ /// Gets chapters for an item
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <returns>IEnumerable{ChapterInfo}.</returns>
+ /// <exception cref="System.ArgumentNullException">id</exception>
+ public IEnumerable<ChapterInfo> GetChapters(Guid id)
+ {
+ CheckDisposed();
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<ChapterInfo>();
+
+ using (var statement = PrepareStatementSafe(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc"))
+ {
+ statement.TryBind("@ItemId", id);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(GetChapter(row));
+ }
+ }
+
+ return list;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets a single chapter for an item
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <param name="index">The index.</param>
+ /// <returns>ChapterInfo.</returns>
+ /// <exception cref="System.ArgumentNullException">id</exception>
+ public ChapterInfo GetChapter(Guid id, int index)
+ {
+ CheckDisposed();
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = PrepareStatementSafe(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex"))
+ {
+ statement.TryBind("@ItemId", id);
+ statement.TryBind("@ChapterIndex", index);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return GetChapter(row);
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /// <summary>
+ /// Gets the chapter.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>ChapterInfo.</returns>
+ private ChapterInfo GetChapter(IReadOnlyList<IResultSetValue> reader)
+ {
+ var chapter = new ChapterInfo
+ {
+ StartPositionTicks = reader.GetInt64(0)
+ };
+
+ if (!reader.IsDBNull(1))
+ {
+ chapter.Name = reader.GetString(1);
+ }
+
+ if (!reader.IsDBNull(2))
+ {
+ chapter.ImagePath = reader.GetString(2);
+ }
+
+ if (!reader.IsDBNull(3))
+ {
+ chapter.ImageDateModified = reader[3].ReadDateTime();
+ }
+
+ return chapter;
+ }
+
+ /// <summary>
+ /// Saves the chapters.
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <param name="chapters">The chapters.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">
+ /// id
+ /// or
+ /// chapters
+ /// or
+ /// cancellationToken
+ /// </exception>
+ public async Task SaveChapters(Guid id, List<ChapterInfo> chapters, CancellationToken cancellationToken)
+ {
+ CheckDisposed();
+
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ if (chapters == null)
+ {
+ throw new ArgumentNullException("chapters");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var index = 0;
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ // First delete chapters
+ db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", id.ToGuidParamValue());
+
+ using (var saveChapterStatement = PrepareStatement(db, "replace into " + ChaptersTableName + " (ItemId, ChapterIndex, StartPositionTicks, Name, ImagePath, ImageDateModified) values (@ItemId, @ChapterIndex, @StartPositionTicks, @Name, @ImagePath, @ImageDateModified)"))
+ {
+ foreach (var chapter in chapters)
+ {
+ if (index > 0)
+ {
+ saveChapterStatement.Reset();
+ }
+
+ saveChapterStatement.TryBind("@ItemId", id.ToGuidParamValue());
+ saveChapterStatement.TryBind("@ChapterIndex", index);
+ saveChapterStatement.TryBind("@StartPositionTicks", chapter.StartPositionTicks);
+ saveChapterStatement.TryBind("@Name", chapter.Name);
+ saveChapterStatement.TryBind("@ImagePath", chapter.ImagePath);
+ saveChapterStatement.TryBind("@ImageDateModified", chapter.ImageDateModified);
+
+ saveChapterStatement.MoveNext();
+
+ index++;
+ }
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private bool EnableJoinUserData(InternalItemsQuery query)
+ {
+ if (query.User == null)
+ {
+ return false;
+ }
+
+ if (query.SimilarTo != null && query.User != null)
+ {
+ //return true;
+ }
+
+ var sortingFields = query.SortBy.ToList();
+ sortingFields.AddRange(query.OrderBy.Select(i => i.Item1));
+
+ if (sortingFields.Contains(ItemSortBy.IsFavoriteOrLiked, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (sortingFields.Contains(ItemSortBy.IsPlayed, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (sortingFields.Contains(ItemSortBy.IsUnplayed, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (sortingFields.Contains(ItemSortBy.PlayCount, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (sortingFields.Contains(ItemSortBy.DatePlayed, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (sortingFields.Contains(ItemSortBy.SeriesDatePlayed, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (query.IsFavoriteOrLiked.HasValue)
+ {
+ return true;
+ }
+
+ if (query.IsFavorite.HasValue)
+ {
+ return true;
+ }
+
+ if (query.IsResumable.HasValue)
+ {
+ return true;
+ }
+
+ if (query.IsPlayed.HasValue)
+ {
+ return true;
+ }
+
+ if (query.IsLiked.HasValue)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private List<ItemFields> allFields = Enum.GetNames(typeof(ItemFields))
+ .Select(i => (ItemFields)Enum.Parse(typeof(ItemFields), i, true))
+ .ToList();
+
+ private IEnumerable<string> GetColumnNamesFromField(ItemFields field)
+ {
+ if (field == ItemFields.Settings)
+ {
+ return new[] { "IsLocked", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "LockedFields" };
+ }
+ if (field == ItemFields.ServiceName)
+ {
+ return new[] { "ExternalServiceId" };
+ }
+ if (field == ItemFields.SortName)
+ {
+ return new[] { "ForcedSortName" };
+ }
+ if (field == ItemFields.Taglines)
+ {
+ return new[] { "Tagline" };
+ }
+
+ return new[] { field.ToString() };
+ }
+
+ private string[] GetFinalColumnsToSelect(InternalItemsQuery query, string[] startColumns)
+ {
+ var list = startColumns.ToList();
+
+ foreach (var field in allFields)
+ {
+ if (!query.HasField(field))
+ {
+ foreach (var fieldToRemove in GetColumnNamesFromField(field).ToList())
+ {
+ list.Remove(fieldToRemove);
+ }
+ }
+ }
+
+ if (!query.DtoOptions.EnableImages)
+ {
+ list.Remove("Images");
+ }
+
+ if (EnableJoinUserData(query))
+ {
+ list.Add("UserData.UserId");
+ list.Add("UserData.lastPlayedDate");
+ list.Add("UserData.playbackPositionTicks");
+ list.Add("UserData.playcount");
+ list.Add("UserData.isFavorite");
+ list.Add("UserData.played");
+ list.Add("UserData.rating");
+ }
+
+ if (query.SimilarTo != null)
+ {
+ var item = query.SimilarTo;
+
+ var builder = new StringBuilder();
+ builder.Append("(");
+
+ builder.Append("((OfficialRating=@ItemOfficialRating) * 10)");
+ //builder.Append("+ ((ProductionYear=@ItemProductionYear) * 10)");
+
+ builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 10 Then 2 Else 0 End )");
+ builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 2 Else 0 End )");
+
+ //// genres, tags
+ builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and Type in (2,3,4,5) and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId and Type in (2,3,4,5))) * 10)");
+
+ //builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and Type=3 and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId and type=3)) * 3)");
+
+ //builder.Append("+ ((Select count(Name) from People where ItemId=Guid and Name in (select Name from People where ItemId=@SimilarItemId)) * 3)");
+
+ ////builder.Append("(select group_concat((Select Name from People where ItemId=Guid and Name in (Select Name from People where ItemId=@SimilarItemId)), '|'))");
+
+ builder.Append(") as SimilarityScore");
+
+ list.Add(builder.ToString());
+
+ var excludeIds = query.ExcludeItemIds.ToList();
+ excludeIds.Add(item.Id.ToString("N"));
+ query.ExcludeItemIds = excludeIds.ToArray();
+
+ query.ExcludeProviderIds = item.ProviderIds;
+ }
+
+ return list.ToArray();
+ }
+
+ private void BindSimilarParams(InternalItemsQuery query, IStatement statement)
+ {
+ var item = query.SimilarTo;
+
+ if (item == null)
+ {
+ return;
+ }
+
+ statement.TryBind("@ItemOfficialRating", item.OfficialRating);
+ statement.TryBind("@ItemProductionYear", item.ProductionYear ?? 0);
+ statement.TryBind("@SimilarItemId", item.Id);
+ }
+
+ private string GetJoinUserDataText(InternalItemsQuery query)
+ {
+ if (!EnableJoinUserData(query))
+ {
+ return string.Empty;
+ }
+
+ return " left join UserData on UserDataKey=UserData.Key And (UserId=@UserId)";
+ }
+
+ private string GetGroupBy(InternalItemsQuery query)
+ {
+ var groups = new List<string>();
+
+ if (EnableGroupByPresentationUniqueKey(query))
+ {
+ groups.Add("PresentationUniqueKey");
+ }
+
+ if (groups.Count > 0)
+ {
+ return " Group by " + string.Join(",", groups.ToArray());
+ }
+
+ return string.Empty;
+ }
+
+ private string GetFromText(string alias = "A")
+ {
+ return " from TypedBaseItems " + alias;
+ }
+
+ public int GetCount(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ //Logger.Info("GetItemList: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ // Hack for right now since we currently don't support filtering out these duplicates within a query
+ if (query.Limit.HasValue && query.EnableGroupByMetadataKey)
+ {
+ query.Limit = query.Limit.Value + 4;
+ }
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new [] { "count(distinct PresentationUniqueKey)" })) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ //commandText += GetGroupBy(query);
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ var count = statement.ExecuteQuery().SelectScalarInt().First();
+ LogQueryTime("GetCount", commandText, now);
+ return count;
+ }
+ }
+
+ }
+ }
+
+ public List<BaseItem> GetItemList(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ //Logger.Info("GetItemList: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ // Hack for right now since we currently don't support filtering out these duplicates within a query
+ if (query.Limit.HasValue && query.EnableGroupByMetadataKey)
+ {
+ query.Limit = query.Limit.Value + 4;
+ }
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ commandText += GetGroupBy(query);
+
+ commandText += GetOrderByText(query);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ return connection.RunInTransaction(db =>
+ {
+ var list = new List<BaseItem>();
+
+ using (var statement = PrepareStatementSafe(db, commandText))
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ var item = GetItem(row, query);
+ if (item != null)
+ {
+ list.Add(item);
+ }
+ }
+ }
+
+ // Hack for right now since we currently don't support filtering out these duplicates within a query
+ if (query.EnableGroupByMetadataKey)
+ {
+ var limit = query.Limit ?? int.MaxValue;
+ limit -= 4;
+ var newList = new List<BaseItem>();
+
+ foreach (var item in list)
+ {
+ AddItem(newList, item);
+
+ if (newList.Count >= limit)
+ {
+ break;
+ }
+ }
+
+ list = newList;
+ }
+
+ LogQueryTime("GetItemList", commandText, now);
+
+ return list;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ private void AddItem(List<BaseItem> items, BaseItem newItem)
+ {
+ var providerIds = newItem.ProviderIds.ToList();
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var item = items[i];
+
+ foreach (var providerId in providerIds)
+ {
+ if (providerId.Key == MetadataProviders.TmdbCollection.ToString())
+ {
+ continue;
+ }
+ if (item.GetProviderId(providerId.Key) == providerId.Value)
+ {
+ if (newItem.SourceType == SourceType.Library)
+ {
+ items[i] = newItem;
+ }
+ return;
+ }
+ }
+ }
+
+ items.Add(newItem);
+ }
+
+ private void LogQueryTime(string methodName, string commandText, DateTime startDate)
+ {
+ var elapsed = (DateTime.UtcNow - startDate).TotalMilliseconds;
+
+ var slowThreshold = 1000;
+
+#if DEBUG
+ slowThreshold = 2;
+#endif
+
+ if (elapsed >= slowThreshold)
+ {
+ Logger.Debug("{2} query time (slow): {0}ms. Query: {1}",
+ Convert.ToInt32(elapsed),
+ commandText,
+ methodName);
+ }
+ else
+ {
+ //Logger.Debug("{2} query time: {0}ms. Query: {1}",
+ // Convert.ToInt32(elapsed),
+ // cmd.CommandText,
+ // methodName);
+ }
+ }
+
+ public QueryResult<BaseItem> GetItems(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ if (!query.EnableTotalRecordCount || (!query.Limit.HasValue && (query.StartIndex ?? 0) == 0))
+ {
+ var returnList = GetItemList(query);
+ return new QueryResult<BaseItem>
+ {
+ Items = returnList.ToArray(),
+ TotalRecordCount = returnList.Count
+ };
+ }
+ //Logger.Info("GetItems: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ var list = new List<BaseItem>();
+
+ // Hack for right now since we currently don't support filtering out these duplicates within a query
+ if (query.Limit.HasValue && query.EnableGroupByMetadataKey)
+ {
+ query.Limit = query.Limit.Value + 4;
+ }
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ var whereTextWithoutPaging = whereText;
+
+ commandText += whereText;
+
+ commandText += GetGroupBy(query);
+
+ commandText += GetOrderByText(query);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0;
+
+ var statementTexts = new List<string>();
+ if (!isReturningZeroItems)
+ {
+ statementTexts.Add(commandText);
+ }
+ if (query.EnableTotalRecordCount)
+ {
+ commandText = string.Empty;
+
+ if (EnableGroupByPresentationUniqueKey(query))
+ {
+ commandText += " select count (distinct PresentationUniqueKey)" + GetFromText();
+ }
+ else
+ {
+ commandText += " select count (guid)" + GetFromText();
+ }
+
+ commandText += GetJoinUserDataText(query);
+ commandText += whereTextWithoutPaging;
+ statementTexts.Add(commandText);
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ return connection.RunInTransaction(db =>
+ {
+ var result = new QueryResult<BaseItem>();
+ var statements = PrepareAllSafe(db, statementTexts)
+ .ToList();
+
+ if (!isReturningZeroItems)
+ {
+ using (var statement = statements[0])
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ var item = GetItem(row, query);
+ if (item != null)
+ {
+ list.Add(item);
+ }
+ }
+ }
+ }
+
+ if (query.EnableTotalRecordCount)
+ {
+ using (var statement = statements[statements.Count - 1])
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First();
+ }
+ }
+
+ LogQueryTime("GetItems", commandText, now);
+
+ result.Items = list.ToArray();
+ return result;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ private string GetOrderByText(InternalItemsQuery query)
+ {
+ var orderBy = query.OrderBy.ToList();
+ var enableOrderInversion = true;
+
+ if (orderBy.Count == 0)
+ {
+ orderBy.AddRange(query.SortBy.Select(i => new Tuple<string, SortOrder>(i, query.SortOrder)));
+ }
+ else
+ {
+ enableOrderInversion = false;
+ }
+
+ if (query.SimilarTo != null)
+ {
+ if (orderBy.Count == 0)
+ {
+ orderBy.Add(new Tuple<string, SortOrder>("SimilarityScore", SortOrder.Descending));
+ orderBy.Add(new Tuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending));
+ query.SortOrder = SortOrder.Descending;
+ enableOrderInversion = false;
+ }
+ }
+
+ query.OrderBy = orderBy;
+
+ if (orderBy.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ return " ORDER BY " + string.Join(",", orderBy.Select(i =>
+ {
+ var columnMap = MapOrderByField(i.Item1, query);
+ var columnAscending = i.Item2 == SortOrder.Ascending;
+ if (columnMap.Item2 && enableOrderInversion)
+ {
+ columnAscending = !columnAscending;
+ }
+
+ var sortOrder = columnAscending ? "ASC" : "DESC";
+
+ return columnMap.Item1 + " " + sortOrder;
+ }).ToArray());
+ }
+
+ private Tuple<string, bool> MapOrderByField(string name, InternalItemsQuery query)
+ {
+ if (string.Equals(name, ItemSortBy.AirTime, StringComparison.OrdinalIgnoreCase))
+ {
+ // TODO
+ return new Tuple<string, bool>("SortName", false);
+ }
+ if (string.Equals(name, ItemSortBy.Runtime, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("RuntimeTicks", false);
+ }
+ if (string.Equals(name, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("RANDOM()", false);
+ }
+ if (string.Equals(name, ItemSortBy.DatePlayed, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("LastPlayedDate", false);
+ }
+ if (string.Equals(name, ItemSortBy.PlayCount, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("PlayCount", false);
+ }
+ if (string.Equals(name, ItemSortBy.IsFavoriteOrLiked, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("IsFavorite", true);
+ }
+ if (string.Equals(name, ItemSortBy.IsFolder, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("IsFolder", true);
+ }
+ if (string.Equals(name, ItemSortBy.IsPlayed, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("played", true);
+ }
+ if (string.Equals(name, ItemSortBy.IsUnplayed, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("played", false);
+ }
+ if (string.Equals(name, ItemSortBy.DateLastContentAdded, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("DateLastMediaAdded", false);
+ }
+ if (string.Equals(name, ItemSortBy.Artist, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("(select CleanValue from itemvalues where ItemId=Guid and Type=0 LIMIT 1)", false);
+ }
+ if (string.Equals(name, ItemSortBy.AlbumArtist, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("(select CleanValue from itemvalues where ItemId=Guid and Type=1 LIMIT 1)", false);
+ }
+ if (string.Equals(name, ItemSortBy.OfficialRating, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("InheritedParentalRatingValue", false);
+ }
+ if (string.Equals(name, ItemSortBy.Studio, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("(select CleanValue from itemvalues where ItemId=Guid and Type=3 LIMIT 1)", false);
+ }
+ if (string.Equals(name, ItemSortBy.SeriesDatePlayed, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, bool>("(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", false);
+ }
+
+ return new Tuple<string, bool>(name, false);
+ }
+
+ public List<Guid> GetItemIdsList(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+ //Logger.Info("GetItemIdsList: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ commandText += GetGroupBy(query);
+
+ commandText += GetOrderByText(query);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<Guid>();
+
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(row[0].ReadGuid());
+ }
+ }
+
+ LogQueryTime("GetItemList", commandText, now);
+
+ return list;
+ }
+ }
+ }
+
+ public List<Tuple<Guid, string>> GetItemIdsWithPath(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ var now = DateTime.UtcNow;
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid", "path" })) + GetFromText();
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ commandText += GetGroupBy(query);
+
+ commandText += GetOrderByText(query);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ var list = new List<Tuple<Guid, string>>();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ var id = row.GetGuid(0);
+ string path = null;
+
+ if (!row.IsDBNull(1))
+ {
+ path = row.GetString(1);
+ }
+ list.Add(new Tuple<Guid, string>(id, path));
+ }
+ }
+ }
+
+ LogQueryTime("GetItemIdsWithPath", commandText, now);
+
+ return list;
+ }
+ }
+
+ public QueryResult<Guid> GetItemIds(InternalItemsQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ if (!query.EnableTotalRecordCount || (!query.Limit.HasValue && (query.StartIndex ?? 0) == 0))
+ {
+ var returnList = GetItemIdsList(query);
+ return new QueryResult<Guid>
+ {
+ Items = returnList.ToArray(),
+ TotalRecordCount = returnList.Count
+ };
+ }
+ //Logger.Info("GetItemIds: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var whereClauses = GetWhereClauses(query, null);
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ var whereTextWithoutPaging = whereText;
+
+ commandText += whereText;
+
+ commandText += GetGroupBy(query);
+
+ commandText += GetOrderByText(query);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ var list = new List<Guid>();
+ var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0;
+
+ var statementTexts = new List<string>();
+ if (!isReturningZeroItems)
+ {
+ statementTexts.Add(commandText);
+ }
+ if (query.EnableTotalRecordCount)
+ {
+ commandText = string.Empty;
+
+ if (EnableGroupByPresentationUniqueKey(query))
+ {
+ commandText += " select count (distinct PresentationUniqueKey)" + GetFromText();
+ }
+ else
+ {
+ commandText += " select count (guid)" + GetFromText();
+ }
+
+ commandText += GetJoinUserDataText(query);
+ commandText += whereTextWithoutPaging;
+ statementTexts.Add(commandText);
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ return connection.RunInTransaction(db =>
+ {
+ var result = new QueryResult<Guid>();
+
+ var statements = PrepareAllSafe(db, statementTexts)
+ .ToList();
+
+ if (!isReturningZeroItems)
+ {
+ using (var statement = statements[0])
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(row[0].ReadGuid());
+ }
+ }
+ }
+
+ if (query.EnableTotalRecordCount)
+ {
+ using (var statement = statements[statements.Count - 1])
+ {
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ BindSimilarParams(query, statement);
+
+ // Running this again will bind the params
+ GetWhereClauses(query, statement);
+
+ result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First();
+ }
+ }
+
+ LogQueryTime("GetItemIds", commandText, now);
+
+ result.Items = list.ToArray();
+ return result;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ private List<string> GetWhereClauses(InternalItemsQuery query, IStatement statement, string paramSuffix = "")
+ {
+ var whereClauses = new List<string>();
+
+ if (EnableJoinUserData(query))
+ {
+ //whereClauses.Add("(UserId is null or UserId=@UserId)");
+ }
+ if (query.IsHD.HasValue)
+ {
+ whereClauses.Add("IsHD=@IsHD");
+ if (statement != null)
+ {
+ statement.TryBind("@IsHD", query.IsHD);
+ }
+ }
+ if (query.IsLocked.HasValue)
+ {
+ whereClauses.Add("IsLocked=@IsLocked");
+ if (statement != null)
+ {
+ statement.TryBind("@IsLocked", query.IsLocked);
+ }
+ }
+
+ var exclusiveProgramAttribtues = !(query.IsMovie ?? true) ||
+ !(query.IsSports ?? true) ||
+ !(query.IsKids ?? true) ||
+ !(query.IsNews ?? true) ||
+ !(query.IsSeries ?? true);
+
+ if (exclusiveProgramAttribtues)
+ {
+ if (query.IsMovie.HasValue)
+ {
+ var alternateTypes = new List<string>();
+ if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Movie).Name))
+ {
+ alternateTypes.Add(typeof(Movie).FullName);
+ }
+ if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Trailer).Name))
+ {
+ alternateTypes.Add(typeof(Trailer).FullName);
+ }
+
+ if (alternateTypes.Count == 0)
+ {
+ whereClauses.Add("IsMovie=@IsMovie");
+ if (statement != null)
+ {
+ statement.TryBind("@IsMovie", query.IsMovie);
+ }
+ }
+ else
+ {
+ whereClauses.Add("(IsMovie is null OR IsMovie=@IsMovie)");
+ if (statement != null)
+ {
+ statement.TryBind("@IsMovie", query.IsMovie);
+ }
+ }
+ }
+ if (query.IsSeries.HasValue)
+ {
+ whereClauses.Add("IsSeries=@IsSeries");
+ if (statement != null)
+ {
+ statement.TryBind("@IsSeries", query.IsSeries);
+ }
+ }
+ if (query.IsNews.HasValue)
+ {
+ whereClauses.Add("IsNews=@IsNews");
+ if (statement != null)
+ {
+ statement.TryBind("@IsNews", query.IsNews);
+ }
+ }
+ if (query.IsKids.HasValue)
+ {
+ whereClauses.Add("IsKids=@IsKids");
+ if (statement != null)
+ {
+ statement.TryBind("@IsKids", query.IsKids);
+ }
+ }
+ if (query.IsSports.HasValue)
+ {
+ whereClauses.Add("IsSports=@IsSports");
+ if (statement != null)
+ {
+ statement.TryBind("@IsSports", query.IsSports);
+ }
+ }
+ }
+ else
+ {
+ var programAttribtues = new List<string>();
+ if (query.IsMovie ?? false)
+ {
+ var alternateTypes = new List<string>();
+ if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Movie).Name))
+ {
+ alternateTypes.Add(typeof(Movie).FullName);
+ }
+ if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Trailer).Name))
+ {
+ alternateTypes.Add(typeof(Trailer).FullName);
+ }
+
+ if (alternateTypes.Count == 0)
+ {
+ programAttribtues.Add("IsMovie=@IsMovie");
+ }
+ else
+ {
+ programAttribtues.Add("(IsMovie is null OR IsMovie=@IsMovie)");
+ }
+
+ if (statement != null)
+ {
+ statement.TryBind("@IsMovie", true);
+ }
+ }
+ if (query.IsSports ?? false)
+ {
+ programAttribtues.Add("IsSports=@IsSports");
+ if (statement != null)
+ {
+ statement.TryBind("@IsSports", query.IsSports);
+ }
+ }
+ if (query.IsNews ?? false)
+ {
+ programAttribtues.Add("IsNews=@IsNews");
+ if (statement != null)
+ {
+ statement.TryBind("@IsNews", query.IsNews);
+ }
+ }
+ if (query.IsSeries ?? false)
+ {
+ programAttribtues.Add("IsSeries=@IsSeries");
+ if (statement != null)
+ {
+ statement.TryBind("@IsSeries", query.IsSeries);
+ }
+ }
+ if (query.IsKids ?? false)
+ {
+ programAttribtues.Add("IsKids=@IsKids");
+ if (statement != null)
+ {
+ statement.TryBind("@IsKids", query.IsKids);
+ }
+ }
+ if (programAttribtues.Count > 0)
+ {
+ whereClauses.Add("(" + string.Join(" OR ", programAttribtues.ToArray()) + ")");
+ }
+ }
+
+ if (query.SimilarTo != null)
+ {
+ whereClauses.Add("SimilarityScore > 0");
+ }
+
+ if (query.IsFolder.HasValue)
+ {
+ whereClauses.Add("IsFolder=@IsFolder");
+ if (statement != null)
+ {
+ statement.TryBind("@IsFolder", query.IsFolder);
+ }
+ }
+
+ var includeTypes = query.IncludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray();
+ if (includeTypes.Length == 1)
+ {
+ whereClauses.Add("type=@type" + paramSuffix);
+ if (statement != null)
+ {
+ statement.TryBind("@type" + paramSuffix, includeTypes[0]);
+ }
+ }
+ else if (includeTypes.Length > 1)
+ {
+ var inClause = string.Join(",", includeTypes.Select(i => "'" + i + "'").ToArray());
+ whereClauses.Add(string.Format("type in ({0})", inClause));
+ }
+
+ var excludeTypes = query.ExcludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray();
+ if (excludeTypes.Length == 1)
+ {
+ whereClauses.Add("type<>@type");
+ if (statement != null)
+ {
+ statement.TryBind("@type", excludeTypes[0]);
+ }
+ }
+ else if (excludeTypes.Length > 1)
+ {
+ var inClause = string.Join(",", excludeTypes.Select(i => "'" + i + "'").ToArray());
+ whereClauses.Add(string.Format("type not in ({0})", inClause));
+ }
+
+ if (query.ChannelIds.Length == 1)
+ {
+ whereClauses.Add("ChannelId=@ChannelId");
+ if (statement != null)
+ {
+ statement.TryBind("@ChannelId", query.ChannelIds[0]);
+ }
+ }
+ if (query.ChannelIds.Length > 1)
+ {
+ var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i + "'").ToArray());
+ whereClauses.Add(string.Format("ChannelId in ({0})", inClause));
+ }
+
+ if (query.ParentId.HasValue)
+ {
+ whereClauses.Add("ParentId=@ParentId");
+ if (statement != null)
+ {
+ statement.TryBind("@ParentId", query.ParentId.Value);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Path))
+ {
+ whereClauses.Add("Path=@Path");
+ if (statement != null)
+ {
+ statement.TryBind("@Path", query.Path);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey))
+ {
+ whereClauses.Add("PresentationUniqueKey=@PresentationUniqueKey");
+ if (statement != null)
+ {
+ statement.TryBind("@PresentationUniqueKey", query.PresentationUniqueKey);
+ }
+ }
+
+ if (query.MinCommunityRating.HasValue)
+ {
+ whereClauses.Add("CommunityRating>=@MinCommunityRating");
+ if (statement != null)
+ {
+ statement.TryBind("@MinCommunityRating", query.MinCommunityRating.Value);
+ }
+ }
+
+ if (query.MinIndexNumber.HasValue)
+ {
+ whereClauses.Add("IndexNumber>=@MinIndexNumber");
+ if (statement != null)
+ {
+ statement.TryBind("@MinIndexNumber", query.MinIndexNumber.Value);
+ }
+ }
+
+ if (query.MinDateCreated.HasValue)
+ {
+ whereClauses.Add("DateCreated>=@MinDateCreated");
+ if (statement != null)
+ {
+ statement.TryBind("@MinDateCreated", query.MinDateCreated.Value);
+ }
+ }
+
+ if (query.MinDateLastSaved.HasValue)
+ {
+ whereClauses.Add("DateLastSaved>=@MinDateLastSaved");
+ if (statement != null)
+ {
+ statement.TryBind("@MinDateLastSaved", query.MinDateLastSaved.Value);
+ }
+ }
+
+ //if (query.MinPlayers.HasValue)
+ //{
+ // whereClauses.Add("Players>=@MinPlayers");
+ // cmd.Parameters.Add(cmd, "@MinPlayers", DbType.Int32).Value = query.MinPlayers.Value;
+ //}
+
+ //if (query.MaxPlayers.HasValue)
+ //{
+ // whereClauses.Add("Players<=@MaxPlayers");
+ // cmd.Parameters.Add(cmd, "@MaxPlayers", DbType.Int32).Value = query.MaxPlayers.Value;
+ //}
+
+ if (query.IndexNumber.HasValue)
+ {
+ whereClauses.Add("IndexNumber=@IndexNumber");
+ if (statement != null)
+ {
+ statement.TryBind("@IndexNumber", query.IndexNumber.Value);
+ }
+ }
+ if (query.ParentIndexNumber.HasValue)
+ {
+ whereClauses.Add("ParentIndexNumber=@ParentIndexNumber");
+ if (statement != null)
+ {
+ statement.TryBind("@ParentIndexNumber", query.ParentIndexNumber.Value);
+ }
+ }
+ if (query.ParentIndexNumberNotEquals.HasValue)
+ {
+ whereClauses.Add("(ParentIndexNumber<>@ParentIndexNumberNotEquals or ParentIndexNumber is null)");
+ if (statement != null)
+ {
+ statement.TryBind("@ParentIndexNumberNotEquals", query.ParentIndexNumberNotEquals.Value);
+ }
+ }
+ if (query.MinEndDate.HasValue)
+ {
+ whereClauses.Add("EndDate>=@MinEndDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MinEndDate", query.MinEndDate.Value);
+ }
+ }
+
+ if (query.MaxEndDate.HasValue)
+ {
+ whereClauses.Add("EndDate<=@MaxEndDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxEndDate", query.MaxEndDate.Value);
+ }
+ }
+
+ if (query.MinStartDate.HasValue)
+ {
+ whereClauses.Add("StartDate>=@MinStartDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MinStartDate", query.MinStartDate.Value);
+ }
+ }
+
+ if (query.MaxStartDate.HasValue)
+ {
+ whereClauses.Add("StartDate<=@MaxStartDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxStartDate", query.MaxStartDate.Value);
+ }
+ }
+
+ if (query.MinPremiereDate.HasValue)
+ {
+ whereClauses.Add("PremiereDate>=@MinPremiereDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MinPremiereDate", query.MinPremiereDate.Value);
+ }
+ }
+ if (query.MaxPremiereDate.HasValue)
+ {
+ whereClauses.Add("PremiereDate<=@MaxPremiereDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxPremiereDate", query.MaxPremiereDate.Value);
+ }
+ }
+
+ if (query.SourceTypes.Length == 1)
+ {
+ whereClauses.Add("SourceType=@SourceType");
+ if (statement != null)
+ {
+ statement.TryBind("@SourceType", query.SourceTypes[0].ToString());
+ }
+ }
+ else if (query.SourceTypes.Length > 1)
+ {
+ var inClause = string.Join(",", query.SourceTypes.Select(i => "'" + i + "'").ToArray());
+ whereClauses.Add(string.Format("SourceType in ({0})", inClause));
+ }
+
+ if (query.ExcludeSourceTypes.Length == 1)
+ {
+ whereClauses.Add("SourceType<>@ExcludeSourceTypes");
+ if (statement != null)
+ {
+ statement.TryBind("@ExcludeSourceTypes", query.ExcludeSourceTypes[0].ToString());
+ }
+ }
+ else if (query.ExcludeSourceTypes.Length > 1)
+ {
+ var inClause = string.Join(",", query.ExcludeSourceTypes.Select(i => "'" + i + "'").ToArray());
+ whereClauses.Add(string.Format("SourceType not in ({0})", inClause));
+ }
+
+ if (query.TrailerTypes.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var type in query.TrailerTypes)
+ {
+ clauses.Add("TrailerTypes like @TrailerTypes" + index);
+ if (statement != null)
+ {
+ statement.TryBind("@TrailerTypes" + index, "%" + type + "%");
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.IsAiring.HasValue)
+ {
+ if (query.IsAiring.Value)
+ {
+ whereClauses.Add("StartDate<=@MaxStartDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxStartDate", DateTime.UtcNow);
+ }
+
+ whereClauses.Add("EndDate>=@MinEndDate");
+ if (statement != null)
+ {
+ statement.TryBind("@MinEndDate", DateTime.UtcNow);
+ }
+ }
+ else
+ {
+ whereClauses.Add("(StartDate>@IsAiringDate OR EndDate < @IsAiringDate)");
+ if (statement != null)
+ {
+ statement.TryBind("@IsAiringDate", DateTime.UtcNow);
+ }
+ }
+ }
+
+ if (query.PersonIds.Length > 0)
+ {
+ // TODO: Should this query with CleanName ?
+
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var personId in query.PersonIds)
+ {
+ var paramName = "@PersonId" + index;
+
+ clauses.Add("(select Name from TypedBaseItems where guid=" + paramName + ") in (select Name from People where ItemId=Guid)");
+ if (statement != null)
+ {
+ statement.TryBind(paramName, personId.ToGuidParamValue());
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Person))
+ {
+ whereClauses.Add("Guid in (select ItemId from People where Name=@PersonName)");
+ if (statement != null)
+ {
+ statement.TryBind("@PersonName", query.Person);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.SlugName))
+ {
+ whereClauses.Add("SlugName=@SlugName");
+ if (statement != null)
+ {
+ statement.TryBind("@SlugName", query.SlugName);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.MinSortName))
+ {
+ whereClauses.Add("SortName>=@MinSortName");
+ if (statement != null)
+ {
+ statement.TryBind("@MinSortName", query.MinSortName);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.ExternalSeriesId))
+ {
+ whereClauses.Add("ExternalSeriesId=@ExternalSeriesId");
+ if (statement != null)
+ {
+ statement.TryBind("@ExternalSeriesId", query.ExternalSeriesId);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.ExternalId))
+ {
+ whereClauses.Add("ExternalId=@ExternalId");
+ if (statement != null)
+ {
+ statement.TryBind("@ExternalId", query.ExternalId);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Name))
+ {
+ whereClauses.Add("CleanName=@Name");
+
+ if (statement != null)
+ {
+ statement.TryBind("@Name", GetCleanValue(query.Name));
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.NameContains))
+ {
+ whereClauses.Add("CleanName like @NameContains");
+ if (statement != null)
+ {
+ statement.TryBind("@NameContains", "%" + GetCleanValue(query.NameContains) + "%");
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
+ {
+ whereClauses.Add("SortName like @NameStartsWith");
+ if (statement != null)
+ {
+ statement.TryBind("@NameStartsWith", query.NameStartsWith + "%");
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
+ {
+ whereClauses.Add("SortName >= @NameStartsWithOrGreater");
+ // lowercase this because SortName is stored as lowercase
+ if (statement != null)
+ {
+ statement.TryBind("@NameStartsWithOrGreater", query.NameStartsWithOrGreater.ToLower());
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(query.NameLessThan))
+ {
+ whereClauses.Add("SortName < @NameLessThan");
+ // lowercase this because SortName is stored as lowercase
+ if (statement != null)
+ {
+ statement.TryBind("@NameLessThan", query.NameLessThan.ToLower());
+ }
+ }
+
+ if (query.ImageTypes.Length > 0)
+ {
+ foreach (var requiredImage in query.ImageTypes)
+ {
+ whereClauses.Add("Images like '%" + requiredImage + "%'");
+ }
+ }
+
+ if (query.IsLiked.HasValue)
+ {
+ if (query.IsLiked.Value)
+ {
+ whereClauses.Add("rating>=@UserRating");
+ if (statement != null)
+ {
+ statement.TryBind("@UserRating", UserItemData.MinLikeValue);
+ }
+ }
+ else
+ {
+ whereClauses.Add("(rating is null or rating<@UserRating)");
+ if (statement != null)
+ {
+ statement.TryBind("@UserRating", UserItemData.MinLikeValue);
+ }
+ }
+ }
+
+ if (query.IsFavoriteOrLiked.HasValue)
+ {
+ if (query.IsFavoriteOrLiked.Value)
+ {
+ whereClauses.Add("IsFavorite=@IsFavoriteOrLiked");
+ }
+ else
+ {
+ whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavoriteOrLiked)");
+ }
+ if (statement != null)
+ {
+ statement.TryBind("@IsFavoriteOrLiked", query.IsFavoriteOrLiked.Value);
+ }
+ }
+
+ if (query.IsFavorite.HasValue)
+ {
+ if (query.IsFavorite.Value)
+ {
+ whereClauses.Add("IsFavorite=@IsFavorite");
+ }
+ else
+ {
+ whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavorite)");
+ }
+ if (statement != null)
+ {
+ statement.TryBind("@IsFavorite", query.IsFavorite.Value);
+ }
+ }
+
+ if (EnableJoinUserData(query))
+ {
+ if (query.IsPlayed.HasValue)
+ {
+ if (query.IsPlayed.Value)
+ {
+ whereClauses.Add("(played=@IsPlayed)");
+ }
+ else
+ {
+ whereClauses.Add("(played is null or played=@IsPlayed)");
+ }
+ if (statement != null)
+ {
+ statement.TryBind("@IsPlayed", query.IsPlayed.Value);
+ }
+ }
+ }
+
+ if (query.IsResumable.HasValue)
+ {
+ if (query.IsResumable.Value)
+ {
+ whereClauses.Add("playbackPositionTicks > 0");
+ }
+ else
+ {
+ whereClauses.Add("(playbackPositionTicks is null or playbackPositionTicks = 0)");
+ }
+ }
+
+ if (query.ArtistNames.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var artist in query.ArtistNames)
+ {
+ clauses.Add("@ArtistName" + index + " in (select CleanValue from itemvalues where ItemId=Guid and Type <= 1)");
+ if (statement != null)
+ {
+ statement.TryBind("@ArtistName" + index, GetCleanValue(artist));
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.ExcludeArtistIds.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var artistId in query.ExcludeArtistIds)
+ {
+ var paramName = "@ExcludeArtistId" + index;
+
+ clauses.Add("(select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from itemvalues where ItemId=Guid and Type<=1)");
+ if (statement != null)
+ {
+ statement.TryBind(paramName, artistId.ToGuidParamValue());
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.GenreIds.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var genreId in query.GenreIds)
+ {
+ var paramName = "@GenreId" + index;
+
+ clauses.Add("(select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from itemvalues where ItemId=Guid and Type=2)");
+ if (statement != null)
+ {
+ statement.TryBind(paramName, genreId.ToGuidParamValue());
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.Genres.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var item in query.Genres)
+ {
+ clauses.Add("@Genre" + index + " in (select CleanValue from itemvalues where ItemId=Guid and Type=2)");
+ if (statement != null)
+ {
+ statement.TryBind("@Genre" + index, GetCleanValue(item));
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.Tags.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var item in query.Tags)
+ {
+ clauses.Add("@Tag" + index + " in (select CleanValue from itemvalues where ItemId=Guid and Type=4)");
+ if (statement != null)
+ {
+ statement.TryBind("@Tag" + index, GetCleanValue(item));
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.StudioIds.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var studioId in query.StudioIds)
+ {
+ var paramName = "@StudioId" + index;
+
+ clauses.Add("(select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from itemvalues where ItemId=Guid and Type=3)");
+ if (statement != null)
+ {
+ statement.TryBind(paramName, studioId.ToGuidParamValue());
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.Studios.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var item in query.Studios)
+ {
+ clauses.Add("@Studio" + index + " in (select CleanValue from itemvalues where ItemId=Guid and Type=3)");
+ if (statement != null)
+ {
+ statement.TryBind("@Studio" + index, GetCleanValue(item));
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.Keywords.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var item in query.Keywords)
+ {
+ clauses.Add("@Keyword" + index + " in (select CleanValue from itemvalues where ItemId=Guid and Type=5)");
+ if (statement != null)
+ {
+ statement.TryBind("@Keyword" + index, GetCleanValue(item));
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.OfficialRatings.Length > 0)
+ {
+ var clauses = new List<string>();
+ var index = 0;
+ foreach (var item in query.OfficialRatings)
+ {
+ clauses.Add("OfficialRating=@OfficialRating" + index);
+ if (statement != null)
+ {
+ statement.TryBind("@OfficialRating" + index, item);
+ }
+ index++;
+ }
+ var clause = "(" + string.Join(" OR ", clauses.ToArray()) + ")";
+ whereClauses.Add(clause);
+ }
+
+ if (query.MinParentalRating.HasValue)
+ {
+ whereClauses.Add("InheritedParentalRatingValue<=@MinParentalRating");
+ if (statement != null)
+ {
+ statement.TryBind("@MinParentalRating", query.MinParentalRating.Value);
+ }
+ }
+
+ if (query.MaxParentalRating.HasValue)
+ {
+ whereClauses.Add("InheritedParentalRatingValue<=@MaxParentalRating");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
+ }
+ }
+
+ if (query.HasParentalRating.HasValue)
+ {
+ if (query.HasParentalRating.Value)
+ {
+ whereClauses.Add("InheritedParentalRatingValue > 0");
+ }
+ else
+ {
+ whereClauses.Add("InheritedParentalRatingValue = 0");
+ }
+ }
+
+ if (query.HasOverview.HasValue)
+ {
+ if (query.HasOverview.Value)
+ {
+ whereClauses.Add("(Overview not null AND Overview<>'')");
+ }
+ else
+ {
+ whereClauses.Add("(Overview is null OR Overview='')");
+ }
+ }
+
+ if (query.HasDeadParentId.HasValue)
+ {
+ if (query.HasDeadParentId.Value)
+ {
+ whereClauses.Add("ParentId NOT NULL AND ParentId NOT IN (select guid from TypedBaseItems)");
+ }
+ }
+
+ if (query.Years.Length == 1)
+ {
+ whereClauses.Add("ProductionYear=@Years");
+ if (statement != null)
+ {
+ statement.TryBind("@Years", query.Years[0].ToString());
+ }
+ }
+ else if (query.Years.Length > 1)
+ {
+ var val = string.Join(",", query.Years.ToArray());
+
+ whereClauses.Add("ProductionYear in (" + val + ")");
+ }
+
+ if (query.LocationTypes.Length == 1)
+ {
+ if (query.LocationTypes[0] == LocationType.Virtual && _config.Configuration.SchemaVersion >= 90)
+ {
+ query.IsVirtualItem = true;
+ }
+ else
+ {
+ whereClauses.Add("LocationType=@LocationType");
+ if (statement != null)
+ {
+ statement.TryBind("@LocationType", query.LocationTypes[0].ToString());
+ }
+ }
+ }
+ else if (query.LocationTypes.Length > 1)
+ {
+ var val = string.Join(",", query.LocationTypes.Select(i => "'" + i + "'").ToArray());
+
+ whereClauses.Add("LocationType in (" + val + ")");
+ }
+ if (query.ExcludeLocationTypes.Length == 1)
+ {
+ if (query.ExcludeLocationTypes[0] == LocationType.Virtual && _config.Configuration.SchemaVersion >= 90)
+ {
+ query.IsVirtualItem = false;
+ }
+ else
+ {
+ whereClauses.Add("LocationType<>@ExcludeLocationTypes");
+ if (statement != null)
+ {
+ statement.TryBind("@ExcludeLocationTypes", query.ExcludeLocationTypes[0].ToString());
+ }
+ }
+ }
+ else if (query.ExcludeLocationTypes.Length > 1)
+ {
+ var val = string.Join(",", query.ExcludeLocationTypes.Select(i => "'" + i + "'").ToArray());
+
+ whereClauses.Add("LocationType not in (" + val + ")");
+ }
+ if (query.IsVirtualItem.HasValue)
+ {
+ whereClauses.Add("IsVirtualItem=@IsVirtualItem");
+ if (statement != null)
+ {
+ statement.TryBind("@IsVirtualItem", query.IsVirtualItem.Value);
+ }
+ }
+ if (query.IsSpecialSeason.HasValue)
+ {
+ if (query.IsSpecialSeason.Value)
+ {
+ whereClauses.Add("IndexNumber = 0");
+ }
+ else
+ {
+ whereClauses.Add("IndexNumber <> 0");
+ }
+ }
+ if (query.IsUnaired.HasValue)
+ {
+ if (query.IsUnaired.Value)
+ {
+ whereClauses.Add("PremiereDate >= DATETIME('now')");
+ }
+ else
+ {
+ whereClauses.Add("PremiereDate < DATETIME('now')");
+ }
+ }
+ if (query.IsMissing.HasValue)
+ {
+ if (query.IsMissing.Value)
+ {
+ whereClauses.Add("(IsVirtualItem=1 AND PremiereDate < DATETIME('now'))");
+ }
+ else
+ {
+ whereClauses.Add("(IsVirtualItem=0 OR PremiereDate >= DATETIME('now'))");
+ }
+ }
+ if (query.IsVirtualUnaired.HasValue)
+ {
+ if (query.IsVirtualUnaired.Value)
+ {
+ whereClauses.Add("(IsVirtualItem=1 AND PremiereDate >= DATETIME('now'))");
+ }
+ else
+ {
+ whereClauses.Add("(IsVirtualItem=0 OR PremiereDate < DATETIME('now'))");
+ }
+ }
+ if (query.MediaTypes.Length == 1)
+ {
+ whereClauses.Add("MediaType=@MediaTypes");
+ if (statement != null)
+ {
+ statement.TryBind("@MediaTypes", query.MediaTypes[0]);
+ }
+ }
+ if (query.MediaTypes.Length > 1)
+ {
+ var val = string.Join(",", query.MediaTypes.Select(i => "'" + i + "'").ToArray());
+
+ whereClauses.Add("MediaType in (" + val + ")");
+ }
+ if (query.ItemIds.Length > 0)
+ {
+ var includeIds = new List<string>();
+
+ var index = 0;
+ foreach (var id in query.ItemIds)
+ {
+ includeIds.Add("Guid = @IncludeId" + index);
+ if (statement != null)
+ {
+ statement.TryBind("@IncludeId" + index, new Guid(id));
+ }
+ index++;
+ }
+
+ whereClauses.Add(string.Join(" OR ", includeIds.ToArray()));
+ }
+ if (query.ExcludeItemIds.Length > 0)
+ {
+ var excludeIds = new List<string>();
+
+ var index = 0;
+ foreach (var id in query.ExcludeItemIds)
+ {
+ excludeIds.Add("Guid <> @ExcludeId" + index);
+ if (statement != null)
+ {
+ statement.TryBind("@ExcludeId" + index, new Guid(id));
+ }
+ index++;
+ }
+
+ whereClauses.Add(string.Join(" AND ", excludeIds.ToArray()));
+ }
+
+ if (query.ExcludeProviderIds.Count > 0)
+ {
+ var excludeIds = new List<string>();
+
+ var index = 0;
+ foreach (var pair in query.ExcludeProviderIds)
+ {
+ if (string.Equals(pair.Key, MetadataProviders.TmdbCollection.ToString(), StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var paramName = "@ExcludeProviderId" + index;
+ //excludeIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")");
+ excludeIds.Add("ProviderIds not like " + paramName);
+ if (statement != null)
+ {
+ statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%");
+ }
+ index++;
+
+ break;
+ }
+
+ whereClauses.Add(string.Join(" AND ", excludeIds.ToArray()));
+ }
+
+ if (query.HasImdbId.HasValue)
+ {
+ whereClauses.Add("ProviderIds like '%imdb=%'");
+ }
+
+ if (query.HasTmdbId.HasValue)
+ {
+ whereClauses.Add("ProviderIds like '%tmdb=%'");
+ }
+
+ if (query.HasTvdbId.HasValue)
+ {
+ whereClauses.Add("ProviderIds like '%tvdb=%'");
+ }
+
+ if (query.AlbumNames.Length > 0)
+ {
+ var clause = "(";
+
+ var index = 0;
+ foreach (var name in query.AlbumNames)
+ {
+ if (index > 0)
+ {
+ clause += " OR ";
+ }
+ clause += "Album=@AlbumName" + index;
+
+ if (statement != null)
+ {
+ statement.TryBind("@AlbumName" + index, name);
+ }
+ index++;
+ }
+
+ clause += ")";
+ whereClauses.Add(clause);
+ }
+ if (query.HasThemeSong.HasValue)
+ {
+ if (query.HasThemeSong.Value)
+ {
+ whereClauses.Add("ThemeSongIds not null");
+ }
+ else
+ {
+ whereClauses.Add("ThemeSongIds is null");
+ }
+ }
+ if (query.HasThemeVideo.HasValue)
+ {
+ if (query.HasThemeVideo.Value)
+ {
+ whereClauses.Add("ThemeVideoIds not null");
+ }
+ else
+ {
+ whereClauses.Add("ThemeVideoIds is null");
+ }
+ }
+
+ //var enableItemsByName = query.IncludeItemsByName ?? query.IncludeItemTypes.Length > 0;
+ var enableItemsByName = query.IncludeItemsByName ?? false;
+
+ if (query.TopParentIds.Length == 1)
+ {
+ if (enableItemsByName)
+ {
+ whereClauses.Add("(TopParentId=@TopParentId or IsItemByName=@IsItemByName)");
+ if (statement != null)
+ {
+ statement.TryBind("@IsItemByName", true);
+ }
+ }
+ else
+ {
+ whereClauses.Add("(TopParentId=@TopParentId)");
+ }
+ if (statement != null)
+ {
+ statement.TryBind("@TopParentId", query.TopParentIds[0]);
+ }
+ }
+ if (query.TopParentIds.Length > 1)
+ {
+ var val = string.Join(",", query.TopParentIds.Select(i => "'" + i + "'").ToArray());
+
+ if (enableItemsByName)
+ {
+ whereClauses.Add("(IsItemByName=@IsItemByName or TopParentId in (" + val + "))");
+ if (statement != null)
+ {
+ statement.TryBind("@IsItemByName", true);
+ }
+ }
+ else
+ {
+ whereClauses.Add("(TopParentId in (" + val + "))");
+ }
+ }
+
+ if (query.AncestorIds.Length == 1)
+ {
+ whereClauses.Add("Guid in (select itemId from AncestorIds where AncestorId=@AncestorId)");
+
+ if (statement != null)
+ {
+ statement.TryBind("@AncestorId", new Guid(query.AncestorIds[0]));
+ }
+ }
+ if (query.AncestorIds.Length > 1)
+ {
+ var inClause = string.Join(",", query.AncestorIds.Select(i => "'" + new Guid(i).ToString("N") + "'").ToArray());
+ whereClauses.Add(string.Format("Guid in (select itemId from AncestorIds where AncestorIdText in ({0}))", inClause));
+ }
+ if (!string.IsNullOrWhiteSpace(query.AncestorWithPresentationUniqueKey))
+ {
+ var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey";
+ whereClauses.Add(string.Format("Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause));
+ if (statement != null)
+ {
+ statement.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey))
+ {
+ whereClauses.Add("SeriesPresentationUniqueKey=@SeriesPresentationUniqueKey");
+
+ if (statement != null)
+ {
+ statement.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey);
+ }
+ }
+
+ if (query.BlockUnratedItems.Length == 1)
+ {
+ whereClauses.Add("(InheritedParentalRatingValue > 0 or UnratedType <> @UnratedType)");
+ if (statement != null)
+ {
+ statement.TryBind("@UnratedType", query.BlockUnratedItems[0].ToString());
+ }
+ }
+ if (query.BlockUnratedItems.Length > 1)
+ {
+ var inClause = string.Join(",", query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'").ToArray());
+ whereClauses.Add(string.Format("(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", inClause));
+ }
+
+ var excludeTagIndex = 0;
+ foreach (var excludeTag in query.ExcludeTags)
+ {
+ whereClauses.Add("(Tags is null OR Tags not like @excludeTag" + excludeTagIndex + ")");
+ if (statement != null)
+ {
+ statement.TryBind("@excludeTag" + excludeTagIndex, "%" + excludeTag + "%");
+ }
+ excludeTagIndex++;
+ }
+
+ excludeTagIndex = 0;
+ foreach (var excludeTag in query.ExcludeInheritedTags)
+ {
+ whereClauses.Add("(InheritedTags is null OR InheritedTags not like @excludeInheritedTag" + excludeTagIndex + ")");
+ if (statement != null)
+ {
+ statement.TryBind("@excludeInheritedTag" + excludeTagIndex, "%" + excludeTag + "%");
+ }
+ excludeTagIndex++;
+ }
+
+ return whereClauses;
+ }
+
+ private string GetCleanValue(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ return value.RemoveDiacritics().ToLower();
+ }
+
+ private bool EnableGroupByPresentationUniqueKey(InternalItemsQuery query)
+ {
+ if (!query.GroupByPresentationUniqueKey)
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey))
+ {
+ return false;
+ }
+
+ if (query.User == null)
+ {
+ return false;
+ }
+
+ if (query.IncludeItemTypes.Length == 0)
+ {
+ return true;
+ }
+
+ var types = new[] {
+ typeof(Episode).Name,
+ typeof(Video).Name ,
+ typeof(Movie).Name ,
+ typeof(MusicVideo).Name ,
+ typeof(Series).Name ,
+ typeof(Season).Name };
+
+ if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static readonly Type[] KnownTypes =
+ {
+ typeof(LiveTvProgram),
+ typeof(LiveTvChannel),
+ typeof(LiveTvVideoRecording),
+ typeof(LiveTvAudioRecording),
+ typeof(Series),
+ typeof(Audio),
+ typeof(MusicAlbum),
+ typeof(MusicArtist),
+ typeof(MusicGenre),
+ typeof(MusicVideo),
+ typeof(Movie),
+ typeof(Playlist),
+ typeof(AudioPodcast),
+ typeof(AudioBook),
+ typeof(Trailer),
+ typeof(BoxSet),
+ typeof(Episode),
+ typeof(Season),
+ typeof(Series),
+ typeof(Book),
+ typeof(CollectionFolder),
+ typeof(Folder),
+ typeof(Game),
+ typeof(GameGenre),
+ typeof(GameSystem),
+ typeof(Genre),
+ typeof(Person),
+ typeof(Photo),
+ typeof(PhotoAlbum),
+ typeof(Studio),
+ typeof(UserRootFolder),
+ typeof(UserView),
+ typeof(Video),
+ typeof(Year),
+ typeof(Channel),
+ typeof(AggregateFolder)
+ };
+
+ public async Task UpdateInheritedValues(CancellationToken cancellationToken)
+ {
+ await UpdateInheritedTags(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task UpdateInheritedTags(CancellationToken cancellationToken)
+ {
+ var newValues = new List<Tuple<Guid, string>>();
+
+ var commandText = "select Guid,InheritedTags,(select group_concat(Tags, '|') from TypedBaseItems where (guid=outer.guid) OR (guid in (Select AncestorId from AncestorIds where ItemId=Outer.guid))) as NewInheritedTags from typedbaseitems as Outer where NewInheritedTags <> InheritedTags";
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ foreach (var row in connection.Query(commandText))
+ {
+ var id = row.GetGuid(0);
+ string value = row.IsDBNull(2) ? null : row.GetString(2);
+
+ newValues.Add(new Tuple<Guid, string>(id, value));
+ }
+
+ Logger.Debug("UpdateInheritedTags - {0} rows", newValues.Count);
+ if (newValues.Count == 0)
+ {
+ return;
+ }
+
+ // write lock here
+ using (var statement = PrepareStatement(connection, "Update TypedBaseItems set InheritedTags=@InheritedTags where Guid=@Guid"))
+ {
+ foreach (var item in newValues)
+ {
+ var paramList = new List<object>();
+
+ paramList.Add(item.Item1);
+ paramList.Add(item.Item2);
+
+ statement.Execute(paramList.ToArray());
+ }
+ }
+ }
+ }
+ }
+
+ private static Dictionary<string, string[]> GetTypeMapDictionary()
+ {
+ var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var t in KnownTypes)
+ {
+ dict[t.Name] = new[] { t.FullName };
+ }
+
+ dict["Recording"] = new[] { typeof(LiveTvAudioRecording).FullName, typeof(LiveTvVideoRecording).FullName };
+ dict["Program"] = new[] { typeof(LiveTvProgram).FullName };
+ dict["TvChannel"] = new[] { typeof(LiveTvChannel).FullName };
+
+ return dict;
+ }
+
+ // Not crazy about having this all the way down here, but at least it's in one place
+ readonly Dictionary<string, string[]> _types = GetTypeMapDictionary();
+
+ private IEnumerable<string> MapIncludeItemTypes(string value)
+ {
+ string[] result;
+ if (_types.TryGetValue(value, out result))
+ {
+ return result;
+ }
+
+ return new[] { value };
+ }
+
+ public async Task DeleteItem(Guid id, CancellationToken cancellationToken)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ // Delete people
+ ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", id.ToGuidParamValue());
+
+ // Delete chapters
+ ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", id.ToGuidParamValue());
+
+ // Delete media streams
+ ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", id.ToGuidParamValue());
+
+ // Delete ancestors
+ ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", id.ToGuidParamValue());
+
+ // Delete item values
+ ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", id.ToGuidParamValue());
+
+ // Delete the item
+ ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", id.ToGuidParamValue());
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private void ExecuteWithSingleParam(IDatabaseConnection db, string query, byte[] value)
+ {
+ using (var statement = PrepareStatement(db, query))
+ {
+ statement.TryBind("@Id", value);
+
+ statement.MoveNext();
+ }
+ }
+
+ public List<string> GetPeopleNames(InternalPeopleQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ var commandText = "select Distinct Name from People";
+
+ var whereClauses = GetPeopleWhereClauses(query, null);
+
+ if (whereClauses.Count > 0)
+ {
+ commandText += " where " + string.Join(" AND ", whereClauses.ToArray());
+ }
+
+ commandText += " order by ListOrder";
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<string>();
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ // Run this again to bind the params
+ GetPeopleWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(row.GetString(0));
+ }
+ }
+ return list;
+ }
+ }
+ }
+
+ public List<PersonInfo> GetPeople(InternalPeopleQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People";
+
+ var whereClauses = GetPeopleWhereClauses(query, null);
+
+ if (whereClauses.Count > 0)
+ {
+ commandText += " where " + string.Join(" AND ", whereClauses.ToArray());
+ }
+
+ commandText += " order by ListOrder";
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<PersonInfo>();
+
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ // Run this again to bind the params
+ GetPeopleWhereClauses(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(GetPerson(row));
+ }
+ }
+
+ return list;
+ }
+ }
+ }
+
+ private List<string> GetPeopleWhereClauses(InternalPeopleQuery query, IStatement statement)
+ {
+ var whereClauses = new List<string>();
+
+ if (query.ItemId != Guid.Empty)
+ {
+ whereClauses.Add("ItemId=@ItemId");
+ if (statement != null)
+ {
+ statement.TryBind("@ItemId", query.ItemId.ToGuidParamValue());
+ }
+ }
+ if (query.AppearsInItemId != Guid.Empty)
+ {
+ whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)");
+ if (statement != null)
+ {
+ statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToGuidParamValue());
+ }
+ }
+ if (query.PersonTypes.Count == 1)
+ {
+ whereClauses.Add("PersonType=@PersonType");
+ if (statement != null)
+ {
+ statement.TryBind("@PersonType", query.PersonTypes[0]);
+ }
+ }
+ if (query.PersonTypes.Count > 1)
+ {
+ var val = string.Join(",", query.PersonTypes.Select(i => "'" + i + "'").ToArray());
+
+ whereClauses.Add("PersonType in (" + val + ")");
+ }
+ if (query.ExcludePersonTypes.Count == 1)
+ {
+ whereClauses.Add("PersonType<>@PersonType");
+ if (statement != null)
+ {
+ statement.TryBind("@PersonType", query.ExcludePersonTypes[0]);
+ }
+ }
+ if (query.ExcludePersonTypes.Count > 1)
+ {
+ var val = string.Join(",", query.ExcludePersonTypes.Select(i => "'" + i + "'").ToArray());
+
+ whereClauses.Add("PersonType not in (" + val + ")");
+ }
+ if (query.MaxListOrder.HasValue)
+ {
+ whereClauses.Add("ListOrder<=@MaxListOrder");
+ if (statement != null)
+ {
+ statement.TryBind("@MaxListOrder", query.MaxListOrder.Value);
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(query.NameContains))
+ {
+ whereClauses.Add("Name like @NameContains");
+ if (statement != null)
+ {
+ statement.TryBind("@NameContains", "%" + query.NameContains + "%");
+ }
+ }
+ if (query.SourceTypes.Length == 1)
+ {
+ whereClauses.Add("(select sourcetype from typedbaseitems where guid=ItemId) = @SourceTypes");
+ if (statement != null)
+ {
+ statement.TryBind("@SourceTypes", query.SourceTypes[0].ToString());
+ }
+ }
+
+ return whereClauses;
+ }
+
+ private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, IDatabaseConnection db, IStatement deleteAncestorsStatement, IStatement updateAncestorsStatement)
+ {
+ if (itemId == Guid.Empty)
+ {
+ throw new ArgumentNullException("itemId");
+ }
+
+ if (ancestorIds == null)
+ {
+ throw new ArgumentNullException("ancestorIds");
+ }
+
+ CheckDisposed();
+
+ // First delete
+ deleteAncestorsStatement.Reset();
+ deleteAncestorsStatement.TryBind("@ItemId", itemId.ToGuidParamValue());
+ deleteAncestorsStatement.MoveNext();
+
+ foreach (var ancestorId in ancestorIds)
+ {
+ updateAncestorsStatement.Reset();
+ updateAncestorsStatement.TryBind("@ItemId", itemId.ToGuidParamValue());
+ updateAncestorsStatement.TryBind("@AncestorId", ancestorId.ToGuidParamValue());
+ updateAncestorsStatement.TryBind("@AncestorIdText", ancestorId.ToString("N"));
+ updateAncestorsStatement.MoveNext();
+ }
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetAllArtists(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 0, 1 }, typeof(MusicArtist).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetArtists(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 0 }, typeof(MusicArtist).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetAlbumArtists(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 1 }, typeof(MusicArtist).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetStudios(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 3 }, typeof(Studio).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetGenres(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 2 }, typeof(Genre).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetGameGenres(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 2 }, typeof(GameGenre).FullName);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetMusicGenres(InternalItemsQuery query)
+ {
+ return GetItemValues(query, new[] { 2 }, typeof(MusicGenre).FullName);
+ }
+
+ public List<string> GetStudioNames()
+ {
+ return GetItemValueNames(new[] { 3 }, new List<string>(), new List<string>());
+ }
+
+ public List<string> GetAllArtistNames()
+ {
+ return GetItemValueNames(new[] { 0, 1 }, new List<string>(), new List<string>());
+ }
+
+ public List<string> GetMusicGenreNames()
+ {
+ return GetItemValueNames(new[] { 2 }, new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }, new List<string>());
+ }
+
+ public List<string> GetGameGenreNames()
+ {
+ return GetItemValueNames(new[] { 2 }, new List<string> { "Game" }, new List<string>());
+ }
+
+ public List<string> GetGenreNames()
+ {
+ return GetItemValueNames(new[] { 2 }, new List<string>(), new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist", "Game", "GameSystem" });
+ }
+
+ private List<string> GetItemValueNames(int[] itemValueTypes, List<string> withItemTypes, List<string> excludeItemTypes)
+ {
+ CheckDisposed();
+
+ withItemTypes = withItemTypes.SelectMany(MapIncludeItemTypes).ToList();
+ excludeItemTypes = excludeItemTypes.SelectMany(MapIncludeItemTypes).ToList();
+
+ var now = DateTime.UtcNow;
+
+ var typeClause = itemValueTypes.Length == 1 ?
+ ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) :
+ ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture)).ToArray()) + ")");
+
+ var commandText = "Select Value From ItemValues where " + typeClause;
+
+ if (withItemTypes.Count > 0)
+ {
+ var typeString = string.Join(",", withItemTypes.Select(i => "'" + i + "'").ToArray());
+ commandText += " AND ItemId In (select guid from typedbaseitems where type in (" + typeString + "))";
+ }
+ if (excludeItemTypes.Count > 0)
+ {
+ var typeString = string.Join(",", excludeItemTypes.Select(i => "'" + i + "'").ToArray());
+ commandText += " AND ItemId not In (select guid from typedbaseitems where type in (" + typeString + "))";
+ }
+
+ commandText += " Group By CleanValue";
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<string>();
+
+ using (var statement = PrepareStatementSafe(connection, commandText))
+ {
+ foreach (var row in statement.ExecuteQuery())
+ {
+ if (!row.IsDBNull(0))
+ {
+ list.Add(row.GetString(0));
+ }
+ }
+ }
+
+ LogQueryTime("GetItemValueNames", commandText, now);
+
+ return list;
+ }
+ }
+ }
+
+ private QueryResult<Tuple<BaseItem, ItemCounts>> GetItemValues(InternalItemsQuery query, int[] itemValueTypes, string returnType)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ if (!query.Limit.HasValue)
+ {
+ query.EnableTotalRecordCount = false;
+ }
+
+ CheckDisposed();
+ //Logger.Info("GetItemValues: " + _environmentInfo.StackTrace);
+
+ var now = DateTime.UtcNow;
+
+ var typeClause = itemValueTypes.Length == 1 ?
+ ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) :
+ ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture)).ToArray()) + ")");
+
+ InternalItemsQuery typeSubQuery = null;
+
+ var itemCountColumns = new List<Tuple<string, string>>();
+
+ var typesToCount = query.IncludeItemTypes.ToList();
+
+ if (typesToCount.Count > 0)
+ {
+ var itemCountColumnQuery = "select group_concat(type, '|')" + GetFromText("B");
+
+ typeSubQuery = new InternalItemsQuery(query.User)
+ {
+ ExcludeItemTypes = query.ExcludeItemTypes,
+ IncludeItemTypes = query.IncludeItemTypes,
+ MediaTypes = query.MediaTypes,
+ AncestorIds = query.AncestorIds,
+ ExcludeItemIds = query.ExcludeItemIds,
+ ItemIds = query.ItemIds,
+ TopParentIds = query.TopParentIds,
+ ParentId = query.ParentId,
+ IsPlayed = query.IsPlayed
+ };
+ var whereClauses = GetWhereClauses(typeSubQuery, null, "itemTypes");
+
+ whereClauses.Add("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND " + typeClause + ")");
+
+ var typeWhereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ itemCountColumnQuery += typeWhereText;
+
+ //itemCountColumnQuery += ")";
+
+ itemCountColumns.Add(new Tuple<string, string>("itemTypes", "(" + itemCountColumnQuery + ") as itemTypes"));
+ }
+
+ var columns = _retriveItemColumns.ToList();
+ columns.AddRange(itemCountColumns.Select(i => i.Item2).ToArray());
+
+ var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, columns.ToArray())) + GetFromText();
+ commandText += GetJoinUserDataText(query);
+
+ var innerQuery = new InternalItemsQuery(query.User)
+ {
+ ExcludeItemTypes = query.ExcludeItemTypes,
+ IncludeItemTypes = query.IncludeItemTypes,
+ MediaTypes = query.MediaTypes,
+ AncestorIds = query.AncestorIds,
+ ExcludeItemIds = query.ExcludeItemIds,
+ ItemIds = query.ItemIds,
+ TopParentIds = query.TopParentIds,
+ ParentId = query.ParentId,
+ IsPlayed = query.IsPlayed
+ };
+
+ var innerWhereClauses = GetWhereClauses(innerQuery, null);
+
+ var innerWhereText = innerWhereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", innerWhereClauses.ToArray());
+
+ var whereText = " where Type=@SelectType";
+
+ if (typesToCount.Count == 0)
+ {
+ whereText += " And CleanName In (Select CleanValue from ItemValues where " + typeClause + " AND ItemId in (select guid from TypedBaseItems" + innerWhereText + "))";
+ }
+ else
+ {
+ //whereText += " And itemTypes not null";
+ whereText += " And CleanName In (Select CleanValue from ItemValues where " + typeClause + " AND ItemId in (select guid from TypedBaseItems" + innerWhereText + "))";
+ }
+
+ var outerQuery = new InternalItemsQuery(query.User)
+ {
+ IsFavorite = query.IsFavorite,
+ IsFavoriteOrLiked = query.IsFavoriteOrLiked,
+ IsLiked = query.IsLiked,
+ IsLocked = query.IsLocked,
+ NameLessThan = query.NameLessThan,
+ NameStartsWith = query.NameStartsWith,
+ NameStartsWithOrGreater = query.NameStartsWithOrGreater,
+ AlbumArtistStartsWithOrGreater = query.AlbumArtistStartsWithOrGreater,
+ Tags = query.Tags,
+ OfficialRatings = query.OfficialRatings,
+ GenreIds = query.GenreIds,
+ Genres = query.Genres,
+ Years = query.Years
+ };
+
+ var outerWhereClauses = GetWhereClauses(outerQuery, null);
+
+ whereText += outerWhereClauses.Count == 0 ?
+ string.Empty :
+ " AND " + string.Join(" AND ", outerWhereClauses.ToArray());
+ //cmd.CommandText += GetGroupBy(query);
+
+ commandText += whereText;
+ commandText += " group by PresentationUniqueKey";
+
+ commandText += " order by SortName";
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0;
+
+ var statementTexts = new List<string>();
+ if (!isReturningZeroItems)
+ {
+ statementTexts.Add(commandText);
+ }
+ if (query.EnableTotalRecordCount)
+ {
+ var countText = "select count (distinct PresentationUniqueKey)" + GetFromText();
+
+ countText += GetJoinUserDataText(query);
+ countText += whereText;
+ statementTexts.Add(countText);
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ return connection.RunInTransaction(db =>
+ {
+ var list = new List<Tuple<BaseItem, ItemCounts>>();
+ var result = new QueryResult<Tuple<BaseItem, ItemCounts>>();
+
+ var statements = PrepareAllSafe(db, statementTexts)
+ .ToList();
+
+ if (!isReturningZeroItems)
+ {
+ using (var statement = statements[0])
+ {
+ statement.TryBind("@SelectType", returnType);
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ if (typeSubQuery != null)
+ {
+ GetWhereClauses(typeSubQuery, null, "itemTypes");
+ }
+ BindSimilarParams(query, statement);
+ GetWhereClauses(innerQuery, statement);
+ GetWhereClauses(outerQuery, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ var item = GetItem(row);
+ if (item != null)
+ {
+ var countStartColumn = columns.Count - 1;
+
+ list.Add(new Tuple<BaseItem, ItemCounts>(item, GetItemCounts(row, countStartColumn, typesToCount)));
+ }
+ }
+
+ LogQueryTime("GetItemValues", commandText, now);
+ }
+ }
+
+ if (query.EnableTotalRecordCount)
+ {
+ commandText = "select count (distinct PresentationUniqueKey)" + GetFromText();
+
+ commandText += GetJoinUserDataText(query);
+ commandText += whereText;
+
+ using (var statement = statements[statements.Count - 1])
+ {
+ statement.TryBind("@SelectType", returnType);
+ if (EnableJoinUserData(query))
+ {
+ statement.TryBind("@UserId", query.User.Id);
+ }
+
+ if (typeSubQuery != null)
+ {
+ GetWhereClauses(typeSubQuery, null, "itemTypes");
+ }
+ BindSimilarParams(query, statement);
+ GetWhereClauses(innerQuery, statement);
+ GetWhereClauses(outerQuery, statement);
+
+ result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First();
+
+ LogQueryTime("GetItemValues", commandText, now);
+ }
+ }
+
+ if (result.TotalRecordCount == 0)
+ {
+ result.TotalRecordCount = list.Count;
+ }
+ result.Items = list.ToArray();
+
+ return result;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ private ItemCounts GetItemCounts(IReadOnlyList<IResultSetValue> reader, int countStartColumn, List<string> typesToCount)
+ {
+ var counts = new ItemCounts();
+
+ if (typesToCount.Count == 0)
+ {
+ return counts;
+ }
+
+ var typeString = reader.IsDBNull(countStartColumn) ? null : reader.GetString(countStartColumn);
+
+ if (string.IsNullOrWhiteSpace(typeString))
+ {
+ return counts;
+ }
+
+ var allTypes = typeString.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
+ .ToLookup(i => i).ToList();
+
+ foreach (var type in allTypes)
+ {
+ var value = type.ToList().Count;
+ var typeName = type.Key;
+
+ if (string.Equals(typeName, typeof(Series).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.SeriesCount = value;
+ }
+ else if (string.Equals(typeName, typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.EpisodeCount = value;
+ }
+ else if (string.Equals(typeName, typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.MovieCount = value;
+ }
+ else if (string.Equals(typeName, typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.AlbumCount = value;
+ }
+ else if (string.Equals(typeName, typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.ArtistCount = value;
+ }
+ else if (string.Equals(typeName, typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.SongCount = value;
+ }
+ else if (string.Equals(typeName, typeof(Game).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.GameCount = value;
+ }
+ else if (string.Equals(typeName, typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase))
+ {
+ counts.TrailerCount = value;
+ }
+ counts.ItemCount += value;
+ }
+
+ return counts;
+ }
+
+ private List<Tuple<int, string>> GetItemValuesToSave(BaseItem item)
+ {
+ var list = new List<Tuple<int, string>>();
+
+ var hasArtist = item as IHasArtist;
+ if (hasArtist != null)
+ {
+ list.AddRange(hasArtist.Artists.Select(i => new Tuple<int, string>(0, i)));
+ }
+
+ var hasAlbumArtist = item as IHasAlbumArtist;
+ if (hasAlbumArtist != null)
+ {
+ list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => new Tuple<int, string>(1, i)));
+ }
+
+ list.AddRange(item.Genres.Select(i => new Tuple<int, string>(2, i)));
+ list.AddRange(item.Studios.Select(i => new Tuple<int, string>(3, i)));
+ list.AddRange(item.Tags.Select(i => new Tuple<int, string>(4, i)));
+ list.AddRange(item.Keywords.Select(i => new Tuple<int, string>(5, i)));
+
+ return list;
+ }
+
+ private void UpdateItemValues(Guid itemId, List<Tuple<int, string>> values, IDatabaseConnection db)
+ {
+ if (itemId == Guid.Empty)
+ {
+ throw new ArgumentNullException("itemId");
+ }
+
+ if (values == null)
+ {
+ throw new ArgumentNullException("keys");
+ }
+
+ CheckDisposed();
+
+ // First delete
+ db.Execute("delete from ItemValues where ItemId=@Id", itemId.ToGuidParamValue());
+
+ using (var statement = PrepareStatement(db, "insert into ItemValues (ItemId, Type, Value, CleanValue) values (@ItemId, @Type, @Value, @CleanValue)"))
+ {
+ foreach (var pair in values)
+ {
+ statement.Reset();
+
+ statement.TryBind("@ItemId", itemId.ToGuidParamValue());
+ statement.TryBind("@Type", pair.Item1);
+ statement.TryBind("@Value", pair.Item2);
+
+ if (pair.Item2 == null)
+ {
+ statement.TryBindNull("@CleanValue");
+ }
+ else
+ {
+ statement.TryBind("@CleanValue", GetCleanValue(pair.Item2));
+ }
+
+ statement.MoveNext();
+ }
+ }
+ }
+
+ public async Task UpdatePeople(Guid itemId, List<PersonInfo> people)
+ {
+ if (itemId == Guid.Empty)
+ {
+ throw new ArgumentNullException("itemId");
+ }
+
+ if (people == null)
+ {
+ throw new ArgumentNullException("people");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ // First delete
+ // "delete from People where ItemId=?"
+ connection.Execute("delete from People where ItemId=?", itemId.ToGuidParamValue());
+
+ var listIndex = 0;
+
+ using (var statement = PrepareStatement(connection,
+ "insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values (@ItemId, @Name, @Role, @PersonType, @SortOrder, @ListOrder)"))
+ {
+ foreach (var person in people)
+ {
+ if (listIndex > 0)
+ {
+ statement.Reset();
+ }
+
+ statement.TryBind("@ItemId", itemId.ToGuidParamValue());
+ statement.TryBind("@Name", person.Name);
+ statement.TryBind("@Role", person.Role);
+ statement.TryBind("@PersonType", person.Type);
+ statement.TryBind("@SortOrder", person.SortOrder);
+ statement.TryBind("@ListOrder", listIndex);
+
+ statement.MoveNext();
+ listIndex++;
+ }
+ }
+ }
+ }
+ }
+
+ private PersonInfo GetPerson(IReadOnlyList<IResultSetValue> reader)
+ {
+ var item = new PersonInfo();
+
+ item.ItemId = reader.GetGuid(0);
+ item.Name = reader.GetString(1);
+
+ if (!reader.IsDBNull(2))
+ {
+ item.Role = reader.GetString(2);
+ }
+
+ if (!reader.IsDBNull(3))
+ {
+ item.Type = reader.GetString(3);
+ }
+
+ if (!reader.IsDBNull(4))
+ {
+ item.SortOrder = reader.GetInt32(4);
+ }
+
+ return item;
+ }
+
+ public IEnumerable<MediaStream> GetMediaStreams(MediaStreamQuery query)
+ {
+ CheckDisposed();
+
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ var cmdText = "select " + string.Join(",", _mediaStreamSaveColumns) + " from mediastreams where";
+
+ cmdText += " ItemId=@ItemId";
+
+ if (query.Type.HasValue)
+ {
+ cmdText += " AND StreamType=@StreamType";
+ }
+
+ if (query.Index.HasValue)
+ {
+ cmdText += " AND StreamIndex=@StreamIndex";
+ }
+
+ cmdText += " order by StreamIndex ASC";
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var list = new List<MediaStream>();
+
+ using (var statement = PrepareStatementSafe(connection, cmdText))
+ {
+ statement.TryBind("@ItemId", query.ItemId.ToGuidParamValue());
+
+ if (query.Type.HasValue)
+ {
+ statement.TryBind("@StreamType", query.Type.Value.ToString());
+ }
+
+ if (query.Index.HasValue)
+ {
+ statement.TryBind("@StreamIndex", query.Index.Value);
+ }
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(GetMediaStream(row));
+ }
+ }
+
+ return list;
+ }
+ }
+ }
+
+ public async Task SaveMediaStreams(Guid id, List<MediaStream> streams, CancellationToken cancellationToken)
+ {
+ CheckDisposed();
+
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ if (streams == null)
+ {
+ throw new ArgumentNullException("streams");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ // First delete chapters
+ connection.Execute("delete from mediastreams where ItemId=@ItemId", id.ToGuidParamValue());
+
+ using (var statement = PrepareStatement(connection, string.Format("replace into mediastreams ({0}) values ({1})",
+ string.Join(",", _mediaStreamSaveColumns),
+ string.Join(",", _mediaStreamSaveColumns.Select(i => "@" + i).ToArray()))))
+ {
+ foreach (var stream in streams)
+ {
+ var paramList = new List<object>();
+
+ paramList.Add(id.ToGuidParamValue());
+ paramList.Add(stream.Index);
+ paramList.Add(stream.Type.ToString());
+ paramList.Add(stream.Codec);
+ paramList.Add(stream.Language);
+ paramList.Add(stream.ChannelLayout);
+ paramList.Add(stream.Profile);
+ paramList.Add(stream.AspectRatio);
+ paramList.Add(stream.Path);
+
+ paramList.Add(stream.IsInterlaced);
+ paramList.Add(stream.BitRate);
+ paramList.Add(stream.Channels);
+ paramList.Add(stream.SampleRate);
+
+ paramList.Add(stream.IsDefault);
+ paramList.Add(stream.IsForced);
+ paramList.Add(stream.IsExternal);
+
+ paramList.Add(stream.Width);
+ paramList.Add(stream.Height);
+ paramList.Add(stream.AverageFrameRate);
+ paramList.Add(stream.RealFrameRate);
+ paramList.Add(stream.Level);
+ paramList.Add(stream.PixelFormat);
+ paramList.Add(stream.BitDepth);
+ paramList.Add(stream.IsExternal);
+ paramList.Add(stream.RefFrames);
+
+ paramList.Add(stream.CodecTag);
+ paramList.Add(stream.Comment);
+ paramList.Add(stream.NalLengthSize);
+ paramList.Add(stream.IsAVC);
+ paramList.Add(stream.Title);
+
+ paramList.Add(stream.TimeBase);
+ paramList.Add(stream.CodecTimeBase);
+
+ statement.Execute(paramList.ToArray());
+ }
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the chapter.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>ChapterInfo.</returns>
+ private MediaStream GetMediaStream(IReadOnlyList<IResultSetValue> reader)
+ {
+ var item = new MediaStream
+ {
+ Index = reader[1].ToInt()
+ };
+
+ item.Type = (MediaStreamType)Enum.Parse(typeof(MediaStreamType), reader[2].ToString(), true);
+
+ if (reader[3].SQLiteType != SQLiteType.Null)
+ {
+ item.Codec = reader[3].ToString();
+ }
+
+ if (reader[4].SQLiteType != SQLiteType.Null)
+ {
+ item.Language = reader[4].ToString();
+ }
+
+ if (reader[5].SQLiteType != SQLiteType.Null)
+ {
+ item.ChannelLayout = reader[5].ToString();
+ }
+
+ if (reader[6].SQLiteType != SQLiteType.Null)
+ {
+ item.Profile = reader[6].ToString();
+ }
+
+ if (reader[7].SQLiteType != SQLiteType.Null)
+ {
+ item.AspectRatio = reader[7].ToString();
+ }
+
+ if (reader[8].SQLiteType != SQLiteType.Null)
+ {
+ item.Path = reader[8].ToString();
+ }
+
+ item.IsInterlaced = reader.GetBoolean(9);
+
+ if (reader[10].SQLiteType != SQLiteType.Null)
+ {
+ item.BitRate = reader.GetInt32(10);
+ }
+
+ if (reader[11].SQLiteType != SQLiteType.Null)
+ {
+ item.Channels = reader.GetInt32(11);
+ }
+
+ if (reader[12].SQLiteType != SQLiteType.Null)
+ {
+ item.SampleRate = reader.GetInt32(12);
+ }
+
+ item.IsDefault = reader.GetBoolean(13);
+ item.IsForced = reader.GetBoolean(14);
+ item.IsExternal = reader.GetBoolean(15);
+
+ if (reader[16].SQLiteType != SQLiteType.Null)
+ {
+ item.Width = reader.GetInt32(16);
+ }
+
+ if (reader[17].SQLiteType != SQLiteType.Null)
+ {
+ item.Height = reader.GetInt32(17);
+ }
+
+ if (reader[18].SQLiteType != SQLiteType.Null)
+ {
+ item.AverageFrameRate = reader.GetFloat(18);
+ }
+
+ if (reader[19].SQLiteType != SQLiteType.Null)
+ {
+ item.RealFrameRate = reader.GetFloat(19);
+ }
+
+ if (reader[20].SQLiteType != SQLiteType.Null)
+ {
+ item.Level = reader.GetFloat(20);
+ }
+
+ if (reader[21].SQLiteType != SQLiteType.Null)
+ {
+ item.PixelFormat = reader[21].ToString();
+ }
+
+ if (reader[22].SQLiteType != SQLiteType.Null)
+ {
+ item.BitDepth = reader.GetInt32(22);
+ }
+
+ if (reader[23].SQLiteType != SQLiteType.Null)
+ {
+ item.IsAnamorphic = reader.GetBoolean(23);
+ }
+
+ if (reader[24].SQLiteType != SQLiteType.Null)
+ {
+ item.RefFrames = reader.GetInt32(24);
+ }
+
+ if (reader[25].SQLiteType != SQLiteType.Null)
+ {
+ item.CodecTag = reader.GetString(25);
+ }
+
+ if (reader[26].SQLiteType != SQLiteType.Null)
+ {
+ item.Comment = reader.GetString(26);
+ }
+
+ if (reader[27].SQLiteType != SQLiteType.Null)
+ {
+ item.NalLengthSize = reader.GetString(27);
+ }
+
+ if (reader[28].SQLiteType != SQLiteType.Null)
+ {
+ item.IsAVC = reader[28].ToBool();
+ }
+
+ if (reader[29].SQLiteType != SQLiteType.Null)
+ {
+ item.Title = reader[29].ToString();
+ }
+
+ if (reader[30].SQLiteType != SQLiteType.Null)
+ {
+ item.TimeBase = reader[30].ToString();
+ }
+
+ if (reader[31].SQLiteType != SQLiteType.Null)
+ {
+ item.CodecTimeBase = reader[31].ToString();
+ }
+
+ return item;
+ }
+
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
new file mode 100644
index 000000000..2e39b038a
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
@@ -0,0 +1,422 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ public class SqliteUserDataRepository : BaseSqliteRepository, IUserDataRepository
+ {
+ private readonly string _importFile;
+ private readonly IFileSystem _fileSystem;
+
+ public SqliteUserDataRepository(ILogger logger, IApplicationPaths appPaths, IFileSystem fileSystem)
+ : base(logger)
+ {
+ _fileSystem = fileSystem;
+ DbFilePath = Path.Combine(appPaths.DataPath, "library.db");
+ _importFile = Path.Combine(appPaths.DataPath, "userdata_v2.db");
+ }
+
+ /// <summary>
+ /// Gets the name of the repository
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get
+ {
+ return "SQLite";
+ }
+ }
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public void Initialize(ReaderWriterLockSlim writeLock, ManagedConnection managedConnection)
+ {
+ _connection = managedConnection;
+
+ WriteLock.Dispose();
+ WriteLock = writeLock;
+
+ using (var connection = CreateConnection())
+ {
+ string[] queries = {
+
+ "create table if not exists userdata (key nvarchar, userId GUID, rating float null, played bit, playCount int, isFavorite bit, playbackPositionTicks bigint, lastPlayedDate datetime null)",
+
+ "create table if not exists DataSettings (IsUserDataImported bit)",
+
+ "drop index if exists idx_userdata",
+ "drop index if exists idx_userdata1",
+ "drop index if exists idx_userdata2",
+ "drop index if exists userdataindex1",
+
+ "create unique index if not exists userdataindex on userdata (key, userId)",
+ "create index if not exists userdataindex2 on userdata (key, userId, played)",
+ "create index if not exists userdataindex3 on userdata (key, userId, playbackPositionTicks)",
+ "create index if not exists userdataindex4 on userdata (key, userId, isFavorite)",
+
+ "pragma shrink_memory"
+ };
+
+ connection.RunQueries(queries);
+
+ connection.RunInTransaction(db =>
+ {
+ var existingColumnNames = GetColumnNames(db, "userdata");
+
+ AddColumn(db, "userdata", "AudioStreamIndex", "int", existingColumnNames);
+ AddColumn(db, "userdata", "SubtitleStreamIndex", "int", existingColumnNames);
+ }, TransactionMode);
+
+ ImportUserDataIfNeeded(connection);
+ }
+ }
+
+ protected override bool EnableTempStoreMemory
+ {
+ get
+ {
+ return true;
+ }
+ }
+
+ private void ImportUserDataIfNeeded(ManagedConnection connection)
+ {
+ if (!_fileSystem.FileExists(_importFile))
+ {
+ return;
+ }
+
+ var fileToImport = _importFile;
+ var isImported = connection.Query("select IsUserDataImported from DataSettings").SelectScalarBool().FirstOrDefault();
+
+ if (isImported)
+ {
+ return;
+ }
+
+ ImportUserData(connection, fileToImport);
+
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("replace into DataSettings (IsUserDataImported) values (@IsUserDataImported)"))
+ {
+ statement.TryBind("@IsUserDataImported", true);
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+
+ private void ImportUserData(ManagedConnection connection, string file)
+ {
+ SqliteExtensions.Attach(connection, file, "UserDataBackup");
+
+ var columns = "key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex";
+
+ connection.RunInTransaction(db =>
+ {
+ db.Execute("REPLACE INTO userdata(" + columns + ") SELECT " + columns + " FROM UserDataBackup.userdata;");
+ }, TransactionMode);
+ }
+
+ /// <summary>
+ /// Saves the user data.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="key">The key.</param>
+ /// <param name="userData">The user data.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">userData
+ /// or
+ /// cancellationToken
+ /// or
+ /// userId
+ /// or
+ /// userDataId</exception>
+ public Task SaveUserData(Guid userId, string key, UserItemData userData, CancellationToken cancellationToken)
+ {
+ if (userData == null)
+ {
+ throw new ArgumentNullException("userData");
+ }
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (string.IsNullOrEmpty(key))
+ {
+ throw new ArgumentNullException("key");
+ }
+
+ return PersistUserData(userId, key, userData, cancellationToken);
+ }
+
+ public Task SaveAllUserData(Guid userId, IEnumerable<UserItemData> userData, CancellationToken cancellationToken)
+ {
+ if (userData == null)
+ {
+ throw new ArgumentNullException("userData");
+ }
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ return PersistAllUserData(userId, userData.ToList(), cancellationToken);
+ }
+
+ /// <summary>
+ /// Persists the user data.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="key">The key.</param>
+ /// <param name="userData">The user data.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task PersistUserData(Guid userId, string key, UserItemData userData, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ SaveUserData(db, userId, key, userData);
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private void SaveUserData(IDatabaseConnection db, Guid userId, string key, UserItemData userData)
+ {
+ using (var statement = db.PrepareStatement("replace into userdata (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)"))
+ {
+ statement.TryBind("@userId", userId.ToGuidParamValue());
+ statement.TryBind("@key", key);
+
+ if (userData.Rating.HasValue)
+ {
+ statement.TryBind("@rating", userData.Rating.Value);
+ }
+ else
+ {
+ statement.TryBindNull("@rating");
+ }
+
+ statement.TryBind("@played", userData.Played);
+ statement.TryBind("@playCount", userData.PlayCount);
+ statement.TryBind("@isFavorite", userData.IsFavorite);
+ statement.TryBind("@playbackPositionTicks", userData.PlaybackPositionTicks);
+
+ if (userData.LastPlayedDate.HasValue)
+ {
+ statement.TryBind("@lastPlayedDate", userData.LastPlayedDate.Value.ToDateTimeParamValue());
+ }
+ else
+ {
+ statement.TryBindNull("@lastPlayedDate");
+ }
+
+ if (userData.AudioStreamIndex.HasValue)
+ {
+ statement.TryBind("@AudioStreamIndex", userData.AudioStreamIndex.Value);
+ }
+ else
+ {
+ statement.TryBindNull("@AudioStreamIndex");
+ }
+
+ if (userData.SubtitleStreamIndex.HasValue)
+ {
+ statement.TryBind("@SubtitleStreamIndex", userData.SubtitleStreamIndex.Value);
+ }
+ else
+ {
+ statement.TryBindNull("@SubtitleStreamIndex");
+ }
+
+ statement.MoveNext();
+ }
+ }
+
+ /// <summary>
+ /// Persist all user data for the specified user
+ /// </summary>
+ private async Task PersistAllUserData(Guid userId, List<UserItemData> userDataList, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ foreach (var userItemData in userDataList)
+ {
+ SaveUserData(db, userId, userItemData.Key, userItemData);
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the user data.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="key">The key.</param>
+ /// <returns>Task{UserItemData}.</returns>
+ /// <exception cref="System.ArgumentNullException">
+ /// userId
+ /// or
+ /// key
+ /// </exception>
+ public UserItemData GetUserData(Guid userId, string key)
+ {
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (string.IsNullOrEmpty(key))
+ {
+ throw new ArgumentNullException("key");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from userdata where key =@Key and userId=@UserId"))
+ {
+ statement.TryBind("@UserId", userId.ToGuidParamValue());
+ statement.TryBind("@Key", key);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return ReadRow(row);
+ }
+ }
+
+ return null;
+ }
+ }
+ }
+
+ public UserItemData GetUserData(Guid userId, List<string> keys)
+ {
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (keys == null)
+ {
+ throw new ArgumentNullException("keys");
+ }
+
+ if (keys.Count == 0)
+ {
+ return null;
+ }
+
+ return GetUserData(userId, keys[0]);
+ }
+
+ /// <summary>
+ /// Return all user-data associated with the given user
+ /// </summary>
+ /// <param name="userId"></param>
+ /// <returns></returns>
+ public IEnumerable<UserItemData> GetAllUserData(Guid userId)
+ {
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ var list = new List<UserItemData>();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection())
+ {
+ using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from userdata where userId=@UserId"))
+ {
+ statement.TryBind("@UserId", userId.ToGuidParamValue());
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(ReadRow(row));
+ }
+ }
+ }
+ }
+
+ return list;
+ }
+
+ /// <summary>
+ /// Read a row from the specified reader into the provided userData object
+ /// </summary>
+ /// <param name="reader"></param>
+ private UserItemData ReadRow(IReadOnlyList<IResultSetValue> reader)
+ {
+ var userData = new UserItemData();
+
+ userData.Key = reader[0].ToString();
+ userData.UserId = reader[1].ReadGuid();
+
+ if (reader[2].SQLiteType != SQLiteType.Null)
+ {
+ userData.Rating = reader[2].ToDouble();
+ }
+
+ userData.Played = reader[3].ToBool();
+ userData.PlayCount = reader[4].ToInt();
+ userData.IsFavorite = reader[5].ToBool();
+ userData.PlaybackPositionTicks = reader[6].ToInt64();
+
+ if (reader[7].SQLiteType != SQLiteType.Null)
+ {
+ userData.LastPlayedDate = reader[7].ReadDateTime();
+ }
+
+ if (reader[8].SQLiteType != SQLiteType.Null)
+ {
+ userData.AudioStreamIndex = reader[8].ToInt();
+ }
+
+ if (reader[9].SQLiteType != SQLiteType.Null)
+ {
+ userData.SubtitleStreamIndex = reader[9].ToInt();
+ }
+
+ return userData;
+ }
+
+ protected override void Dispose(bool dispose)
+ {
+ // handled by library database
+ }
+
+ protected override void CloseConnection()
+ {
+ // handled by library database
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Data/SqliteUserRepository.cs b/Emby.Server.Implementations/Data/SqliteUserRepository.cs
new file mode 100644
index 000000000..b2b917e5e
--- /dev/null
+++ b/Emby.Server.Implementations/Data/SqliteUserRepository.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Data
+{
+ /// <summary>
+ /// Class SQLiteUserRepository
+ /// </summary>
+ public class SqliteUserRepository : BaseSqliteRepository, IUserRepository
+ {
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ public SqliteUserRepository(ILogger logger, IServerApplicationPaths appPaths, IJsonSerializer jsonSerializer, IMemoryStreamFactory memoryStreamProvider)
+ : base(logger)
+ {
+ _jsonSerializer = jsonSerializer;
+ _memoryStreamProvider = memoryStreamProvider;
+
+ DbFilePath = Path.Combine(appPaths.DataPath, "users.db");
+ }
+
+ /// <summary>
+ /// Gets the name of the repository
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get
+ {
+ return "SQLite";
+ }
+ }
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists users (guid GUID primary key, data BLOB)",
+ "create index if not exists idx_users on users(guid)",
+ "create table if not exists schema_version (table_name primary key, version)",
+
+ "pragma shrink_memory"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ /// <summary>
+ /// Save a user in the repo
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ public async Task SaveUser(User user, CancellationToken cancellationToken)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var serialized = _jsonSerializer.SerializeToBytes(user, _memoryStreamProvider);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("replace into users (guid, data) values (@guid, @data)"))
+ {
+ statement.TryBind("@guid", user.Id.ToGuidParamValue());
+ statement.TryBind("@data", serialized);
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Retrieve all users from the database
+ /// </summary>
+ /// <returns>IEnumerable{User}.</returns>
+ public IEnumerable<User> RetrieveAllUsers()
+ {
+ var list = new List<User>();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ foreach (var row in connection.Query("select guid,data from users"))
+ {
+ var id = row[0].ReadGuid();
+
+ using (var stream = _memoryStreamProvider.CreateNew(row[1].ToBlob()))
+ {
+ stream.Position = 0;
+ var user = _jsonSerializer.DeserializeFromStream<User>(stream);
+ user.Id = id;
+ list.Add(user);
+ }
+ }
+ }
+ }
+
+ return list;
+ }
+
+ /// <summary>
+ /// Deletes the user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ public async Task DeleteUser(User user, CancellationToken cancellationToken)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("delete from users where guid=@id"))
+ {
+ statement.TryBind("@id", user.Id.ToGuidParamValue());
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Data/TypeMapper.cs b/Emby.Server.Implementations/Data/TypeMapper.cs
new file mode 100644
index 000000000..f4b37749e
--- /dev/null
+++ b/Emby.Server.Implementations/Data/TypeMapper.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Concurrent;
+using MediaBrowser.Model.Reflection;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Data
+{
+ /// <summary>
+ /// Class TypeMapper
+ /// </summary>
+ public class TypeMapper
+ {
+ private readonly IAssemblyInfo _assemblyInfo;
+
+ /// <summary>
+ /// This holds all the types in the running assemblies so that we can de-serialize properly when we don't have strong types
+ /// </summary>
+ private readonly ConcurrentDictionary<string, Type> _typeMap = new ConcurrentDictionary<string, Type>();
+
+ public TypeMapper(IAssemblyInfo assemblyInfo)
+ {
+ _assemblyInfo = assemblyInfo;
+ }
+
+ /// <summary>
+ /// Gets the type.
+ /// </summary>
+ /// <param name="typeName">Name of the type.</param>
+ /// <returns>Type.</returns>
+ /// <exception cref="System.ArgumentNullException"></exception>
+ public Type GetType(string typeName)
+ {
+ if (string.IsNullOrEmpty(typeName))
+ {
+ throw new ArgumentNullException("typeName");
+ }
+
+ return _typeMap.GetOrAdd(typeName, LookupType);
+ }
+
+ /// <summary>
+ /// Lookups the type.
+ /// </summary>
+ /// <param name="typeName">Name of the type.</param>
+ /// <returns>Type.</returns>
+ private Type LookupType(string typeName)
+ {
+ return _assemblyInfo
+ .GetCurrentAssemblies()
+ .Select(a => a.GetType(typeName))
+ .FirstOrDefault(t => t != null);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Devices/CameraUploadsDynamicFolder.cs b/Emby.Server.Implementations/Devices/CameraUploadsDynamicFolder.cs
new file mode 100644
index 000000000..e2d5d0272
--- /dev/null
+++ b/Emby.Server.Implementations/Devices/CameraUploadsDynamicFolder.cs
@@ -0,0 +1,41 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Server.Implementations.Devices;
+
+namespace Emby.Server.Implementations.Devices
+{
+ public class CameraUploadsDynamicFolder : IVirtualFolderCreator
+ {
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+
+ public CameraUploadsDynamicFolder(IApplicationPaths appPaths, IFileSystem fileSystem)
+ {
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ }
+
+ public BasePluginFolder GetFolder()
+ {
+ var path = Path.Combine(_appPaths.DataPath, "camerauploads");
+
+ _fileSystem.CreateDirectory(path);
+
+ return new CameraUploadsFolder
+ {
+ Path = path
+ };
+ }
+ }
+
+}
diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs
new file mode 100644
index 000000000..88c0ea203
--- /dev/null
+++ b/Emby.Server.Implementations/Devices/DeviceManager.cs
@@ -0,0 +1,299 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Users;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+
+namespace Emby.Server.Implementations.Devices
+{
+ public class DeviceManager : IDeviceManager
+ {
+ private readonly IDeviceRepository _repo;
+ private readonly IUserManager _userManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILogger _logger;
+ private readonly INetworkManager _network;
+
+ public event EventHandler<GenericEventArgs<CameraImageUploadInfo>> CameraImageUploaded;
+
+ /// <summary>
+ /// Occurs when [device options updated].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<DeviceInfo>> DeviceOptionsUpdated;
+
+ public DeviceManager(IDeviceRepository repo, IUserManager userManager, IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IServerConfigurationManager config, ILogger logger, INetworkManager network)
+ {
+ _repo = repo;
+ _userManager = userManager;
+ _fileSystem = fileSystem;
+ _libraryMonitor = libraryMonitor;
+ _config = config;
+ _logger = logger;
+ _network = network;
+ }
+
+ public async Task<DeviceInfo> RegisterDevice(string reportedId, string name, string appName, string appVersion, string usedByUserId)
+ {
+ if (string.IsNullOrWhiteSpace(reportedId))
+ {
+ throw new ArgumentNullException("reportedId");
+ }
+
+ var device = GetDevice(reportedId) ?? new DeviceInfo
+ {
+ Id = reportedId
+ };
+
+ device.ReportedName = name;
+ device.AppName = appName;
+ device.AppVersion = appVersion;
+
+ if (!string.IsNullOrWhiteSpace(usedByUserId))
+ {
+ var user = _userManager.GetUserById(usedByUserId);
+
+ device.LastUserId = user.Id.ToString("N");
+ device.LastUserName = user.Name;
+ }
+
+ device.DateLastModified = DateTime.UtcNow;
+
+ await _repo.SaveDevice(device).ConfigureAwait(false);
+
+ return device;
+ }
+
+ public Task SaveCapabilities(string reportedId, ClientCapabilities capabilities)
+ {
+ return _repo.SaveCapabilities(reportedId, capabilities);
+ }
+
+ public ClientCapabilities GetCapabilities(string reportedId)
+ {
+ return _repo.GetCapabilities(reportedId);
+ }
+
+ public DeviceInfo GetDevice(string id)
+ {
+ return _repo.GetDevice(id);
+ }
+
+ public QueryResult<DeviceInfo> GetDevices(DeviceQuery query)
+ {
+ IEnumerable<DeviceInfo> devices = _repo.GetDevices().OrderByDescending(i => i.DateLastModified);
+
+ if (query.SupportsSync.HasValue)
+ {
+ var val = query.SupportsSync.Value;
+
+ devices = devices.Where(i => GetCapabilities(i.Id).SupportsSync == val);
+ }
+
+ if (query.SupportsPersistentIdentifier.HasValue)
+ {
+ var val = query.SupportsPersistentIdentifier.Value;
+
+ devices = devices.Where(i =>
+ {
+ var caps = GetCapabilities(i.Id);
+ var deviceVal = caps.SupportsPersistentIdentifier;
+ return deviceVal == val;
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.UserId))
+ {
+ devices = devices.Where(i => CanAccessDevice(query.UserId, i.Id));
+ }
+
+ var array = devices.ToArray();
+ return new QueryResult<DeviceInfo>
+ {
+ Items = array,
+ TotalRecordCount = array.Length
+ };
+ }
+
+ public Task DeleteDevice(string id)
+ {
+ return _repo.DeleteDevice(id);
+ }
+
+ public ContentUploadHistory GetCameraUploadHistory(string deviceId)
+ {
+ return _repo.GetCameraUploadHistory(deviceId);
+ }
+
+ public async Task AcceptCameraUpload(string deviceId, Stream stream, LocalFileInfo file)
+ {
+ var device = GetDevice(deviceId);
+ var path = GetUploadPath(device);
+
+ if (!string.IsNullOrWhiteSpace(file.Album))
+ {
+ path = Path.Combine(path, _fileSystem.GetValidFilename(file.Album));
+ }
+
+ path = Path.Combine(path, file.Name);
+ path = Path.ChangeExtension(path, MimeTypes.ToExtension(file.MimeType) ?? "jpg");
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ try
+ {
+ using (var fs = _fileSystem.GetFileStream(path, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ await stream.CopyToAsync(fs).ConfigureAwait(false);
+ }
+
+ _repo.AddCameraUpload(deviceId, file);
+ }
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(path, true);
+ }
+
+ if (CameraImageUploaded != null)
+ {
+ EventHelper.FireEventIfNotNull(CameraImageUploaded, this, new GenericEventArgs<CameraImageUploadInfo>
+ {
+ Argument = new CameraImageUploadInfo
+ {
+ Device = device,
+ FileInfo = file
+ }
+ }, _logger);
+ }
+ }
+
+ private string GetUploadPath(DeviceInfo device)
+ {
+ if (!string.IsNullOrWhiteSpace(device.CameraUploadPath))
+ {
+ return device.CameraUploadPath;
+ }
+
+ var config = _config.GetUploadOptions();
+ if (!string.IsNullOrWhiteSpace(config.CameraUploadPath))
+ {
+ return config.CameraUploadPath;
+ }
+
+ var path = DefaultCameraUploadsPath;
+
+ if (config.EnableCameraUploadSubfolders)
+ {
+ path = Path.Combine(path, _fileSystem.GetValidFilename(device.Name));
+ }
+
+ return path;
+ }
+
+ private string DefaultCameraUploadsPath
+ {
+ get { return Path.Combine(_config.CommonApplicationPaths.DataPath, "camerauploads"); }
+ }
+
+ public async Task UpdateDeviceInfo(string id, DeviceOptions options)
+ {
+ var device = GetDevice(id);
+
+ device.CustomName = options.CustomName;
+ device.CameraUploadPath = options.CameraUploadPath;
+
+ await _repo.SaveDevice(device).ConfigureAwait(false);
+
+ EventHelper.FireEventIfNotNull(DeviceOptionsUpdated, this, new GenericEventArgs<DeviceInfo>(device), _logger);
+ }
+
+ public bool CanAccessDevice(string userId, string deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (string.IsNullOrWhiteSpace(deviceId))
+ {
+ throw new ArgumentNullException("deviceId");
+ }
+
+ var user = _userManager.GetUserById(userId);
+
+ if (user == null)
+ {
+ throw new ArgumentException("user not found");
+ }
+
+ if (!CanAccessDevice(user.Policy, deviceId))
+ {
+ var capabilities = GetCapabilities(deviceId);
+
+ if (capabilities != null && capabilities.SupportsPersistentIdentifier)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool CanAccessDevice(UserPolicy policy, string id)
+ {
+ if (policy.EnableAllDevices)
+ {
+ return true;
+ }
+
+ if (policy.IsAdministrator)
+ {
+ return true;
+ }
+
+ return ListHelper.ContainsIgnoreCase(policy.EnabledDevices, id);
+ }
+ }
+
+ public class DevicesConfigStore : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ Key = "devices",
+ ConfigurationType = typeof(DevicesOptions)
+ }
+ };
+ }
+ }
+
+ public static class UploadConfigExtension
+ {
+ public static DevicesOptions GetUploadOptions(this IConfigurationManager config)
+ {
+ return config.GetConfiguration<DevicesOptions>("devices");
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Devices/DeviceRepository.cs b/Emby.Server.Implementations/Devices/DeviceRepository.cs
new file mode 100644
index 000000000..f739765b3
--- /dev/null
+++ b/Emby.Server.Implementations/Devices/DeviceRepository.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Session;
+
+namespace Emby.Server.Implementations.Devices
+{
+ public class DeviceRepository : IDeviceRepository
+ {
+ private readonly object _syncLock = new object();
+
+ private readonly IApplicationPaths _appPaths;
+ private readonly IJsonSerializer _json;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+
+ private Dictionary<string, DeviceInfo> _devices;
+
+ public DeviceRepository(IApplicationPaths appPaths, IJsonSerializer json, ILogger logger, IFileSystem fileSystem)
+ {
+ _appPaths = appPaths;
+ _json = json;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ private string GetDevicesPath()
+ {
+ return Path.Combine(_appPaths.DataPath, "devices");
+ }
+
+ private string GetDevicePath(string id)
+ {
+ return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N"));
+ }
+
+ public Task SaveDevice(DeviceInfo device)
+ {
+ var path = Path.Combine(GetDevicePath(device.Id), "device.json");
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ lock (_syncLock)
+ {
+ _json.SerializeToFile(device, path);
+ _devices[device.Id] = device;
+ }
+ return Task.FromResult(true);
+ }
+
+ public Task SaveCapabilities(string reportedId, ClientCapabilities capabilities)
+ {
+ var device = GetDevice(reportedId);
+
+ if (device == null)
+ {
+ throw new ArgumentException("No device has been registed with id " + reportedId);
+ }
+
+ device.Capabilities = capabilities;
+ SaveDevice(device);
+
+ return Task.FromResult(true);
+ }
+
+ public ClientCapabilities GetCapabilities(string reportedId)
+ {
+ var device = GetDevice(reportedId);
+
+ return device == null ? null : device.Capabilities;
+ }
+
+ public DeviceInfo GetDevice(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ return GetDevices()
+ .FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public IEnumerable<DeviceInfo> GetDevices()
+ {
+ lock (_syncLock)
+ {
+ if (_devices == null)
+ {
+ _devices = new Dictionary<string, DeviceInfo>(StringComparer.OrdinalIgnoreCase);
+
+ var devices = LoadDevices().ToList();
+ foreach (var device in devices)
+ {
+ _devices[device.Id] = device;
+ }
+ }
+ return _devices.Values.ToList();
+ }
+ }
+
+ private IEnumerable<DeviceInfo> LoadDevices()
+ {
+ var path = GetDevicesPath();
+
+ try
+ {
+ return _fileSystem
+ .GetFilePaths(path, true)
+ .Where(i => string.Equals(Path.GetFileName(i), "device.json", StringComparison.OrdinalIgnoreCase))
+ .ToList()
+ .Select(i =>
+ {
+ try
+ {
+ return _json.DeserializeFromFile<DeviceInfo>(i);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reading {0}", ex, i);
+ return null;
+ }
+ })
+ .Where(i => i != null);
+ }
+ catch (IOException)
+ {
+ return new List<DeviceInfo>();
+ }
+ }
+
+ public Task DeleteDevice(string id)
+ {
+ var path = GetDevicePath(id);
+
+ lock (_syncLock)
+ {
+ try
+ {
+ _fileSystem.DeleteDirectory(path, true);
+ }
+ catch (IOException)
+ {
+ }
+
+ _devices = null;
+ }
+
+ return Task.FromResult(true);
+ }
+
+ public ContentUploadHistory GetCameraUploadHistory(string deviceId)
+ {
+ var path = Path.Combine(GetDevicePath(deviceId), "camerauploads.json");
+
+ lock (_syncLock)
+ {
+ try
+ {
+ return _json.DeserializeFromFile<ContentUploadHistory>(path);
+ }
+ catch (IOException)
+ {
+ return new ContentUploadHistory
+ {
+ DeviceId = deviceId
+ };
+ }
+ }
+ }
+
+ public void AddCameraUpload(string deviceId, LocalFileInfo file)
+ {
+ var path = Path.Combine(GetDevicePath(deviceId), "camerauploads.json");
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ lock (_syncLock)
+ {
+ ContentUploadHistory history;
+
+ try
+ {
+ history = _json.DeserializeFromFile<ContentUploadHistory>(path);
+ }
+ catch (IOException)
+ {
+ history = new ContentUploadHistory
+ {
+ DeviceId = deviceId
+ };
+ }
+
+ history.DeviceId = deviceId;
+ history.FilesUploaded.Add(file);
+
+ _json.SerializeToFile(history, path);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
new file mode 100644
index 000000000..8e6c1263d
--- /dev/null
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -0,0 +1,1693 @@
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.Dto
+{
+ public class DtoService : IDtoService
+ {
+ private readonly ILogger _logger;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserDataManager _userDataRepository;
+ private readonly IItemRepository _itemRepo;
+
+ private readonly IImageProcessor _imageProcessor;
+ private readonly IServerConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+ private readonly IProviderManager _providerManager;
+
+ private readonly Func<IChannelManager> _channelManagerFactory;
+ private readonly ISyncManager _syncManager;
+ private readonly IApplicationHost _appHost;
+ private readonly Func<IDeviceManager> _deviceManager;
+ private readonly Func<IMediaSourceManager> _mediaSourceManager;
+ private readonly Func<ILiveTvManager> _livetvManager;
+
+ public DtoService(ILogger logger, ILibraryManager libraryManager, IUserDataManager userDataRepository, IItemRepository itemRepo, IImageProcessor imageProcessor, IServerConfigurationManager config, IFileSystem fileSystem, IProviderManager providerManager, Func<IChannelManager> channelManagerFactory, ISyncManager syncManager, IApplicationHost appHost, Func<IDeviceManager> deviceManager, Func<IMediaSourceManager> mediaSourceManager, Func<ILiveTvManager> livetvManager)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _userDataRepository = userDataRepository;
+ _itemRepo = itemRepo;
+ _imageProcessor = imageProcessor;
+ _config = config;
+ _fileSystem = fileSystem;
+ _providerManager = providerManager;
+ _channelManagerFactory = channelManagerFactory;
+ _syncManager = syncManager;
+ _appHost = appHost;
+ _deviceManager = deviceManager;
+ _mediaSourceManager = mediaSourceManager;
+ _livetvManager = livetvManager;
+ }
+
+ /// <summary>
+ /// Converts a BaseItem to a DTOBaseItem
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="fields">The fields.</param>
+ /// <param name="user">The user.</param>
+ /// <param name="owner">The owner.</param>
+ /// <returns>Task{DtoBaseItem}.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public BaseItemDto GetBaseItemDto(BaseItem item, List<ItemFields> fields, User user = null, BaseItem owner = null)
+ {
+ var options = new DtoOptions
+ {
+ Fields = fields
+ };
+
+ return GetBaseItemDto(item, options, user, owner);
+ }
+
+ public async Task<List<BaseItemDto>> GetBaseItemDtos(IEnumerable<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null)
+ {
+ if (items == null)
+ {
+ throw new ArgumentNullException("items");
+ }
+
+ if (options == null)
+ {
+ throw new ArgumentNullException("options");
+ }
+
+ var syncDictionary = GetSyncedItemProgress(options);
+
+ var list = new List<BaseItemDto>();
+ var programTuples = new List<Tuple<BaseItem, BaseItemDto>>();
+ var channelTuples = new List<Tuple<BaseItemDto, LiveTvChannel>>();
+
+ foreach (var item in items)
+ {
+ var dto = await GetBaseItemDtoInternal(item, options, user, owner).ConfigureAwait(false);
+
+ var tvChannel = item as LiveTvChannel;
+ if (tvChannel != null)
+ {
+ channelTuples.Add(new Tuple<BaseItemDto, LiveTvChannel>(dto, tvChannel));
+ }
+ else if (item is LiveTvProgram)
+ {
+ programTuples.Add(new Tuple<BaseItem, BaseItemDto>(item, dto));
+ }
+
+ var byName = item as IItemByName;
+
+ if (byName != null)
+ {
+ if (options.Fields.Contains(ItemFields.ItemCounts))
+ {
+ var libraryItems = byName.GetTaggedItems(new InternalItemsQuery(user)
+ {
+ Recursive = true
+ });
+
+ SetItemByNameInfo(item, dto, libraryItems.ToList(), user);
+ }
+ }
+
+ FillSyncInfo(dto, item, options, user, syncDictionary);
+
+ list.Add(dto);
+ }
+
+ if (programTuples.Count > 0)
+ {
+ await _livetvManager().AddInfoToProgramDto(programTuples, options.Fields, user).ConfigureAwait(false);
+ }
+
+ if (channelTuples.Count > 0)
+ {
+ _livetvManager().AddChannelInfo(channelTuples, options, user);
+ }
+
+ return list;
+ }
+
+ public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null)
+ {
+ var syncDictionary = GetSyncedItemProgress(options);
+
+ var dto = GetBaseItemDtoInternal(item, options, user, owner).Result;
+ var tvChannel = item as LiveTvChannel;
+ if (tvChannel != null)
+ {
+ var list = new List<Tuple<BaseItemDto, LiveTvChannel>> { new Tuple<BaseItemDto, LiveTvChannel>(dto, tvChannel) };
+ _livetvManager().AddChannelInfo(list, options, user);
+ }
+ else if (item is LiveTvProgram)
+ {
+ var list = new List<Tuple<BaseItem, BaseItemDto>> { new Tuple<BaseItem, BaseItemDto>(item, dto) };
+ var task = _livetvManager().AddInfoToProgramDto(list, options.Fields, user);
+ Task.WaitAll(task);
+ }
+
+ var byName = item as IItemByName;
+
+ if (byName != null)
+ {
+ if (options.Fields.Contains(ItemFields.ItemCounts))
+ {
+ SetItemByNameInfo(item, dto, GetTaggedItems(byName, user), user);
+ }
+
+ FillSyncInfo(dto, item, options, user, syncDictionary);
+ return dto;
+ }
+
+ FillSyncInfo(dto, item, options, user, syncDictionary);
+
+ return dto;
+ }
+
+ private List<BaseItem> GetTaggedItems(IItemByName byName, User user)
+ {
+ var items = byName.GetTaggedItems(new InternalItemsQuery(user)
+ {
+ Recursive = true
+
+ }).ToList();
+
+ return items;
+ }
+
+ public Dictionary<string, SyncedItemProgress> GetSyncedItemProgress(DtoOptions options)
+ {
+ if (!options.Fields.Contains(ItemFields.BasicSyncInfo) &&
+ !options.Fields.Contains(ItemFields.SyncInfo))
+ {
+ return new Dictionary<string, SyncedItemProgress>();
+ }
+
+ var deviceId = options.DeviceId;
+ if (string.IsNullOrWhiteSpace(deviceId))
+ {
+ return new Dictionary<string, SyncedItemProgress>();
+ }
+
+ var caps = _deviceManager().GetCapabilities(deviceId);
+ if (caps == null || !caps.SupportsSync)
+ {
+ return new Dictionary<string, SyncedItemProgress>();
+ }
+
+ return _syncManager.GetSyncedItemProgresses(new SyncJobItemQuery
+ {
+ TargetId = deviceId,
+ Statuses = new[]
+ {
+ SyncJobItemStatus.Converting,
+ SyncJobItemStatus.Queued,
+ SyncJobItemStatus.Transferring,
+ SyncJobItemStatus.ReadyToTransfer,
+ SyncJobItemStatus.Synced
+ }
+ });
+ }
+
+ public void FillSyncInfo(IEnumerable<Tuple<BaseItem, BaseItemDto>> tuples, DtoOptions options, User user)
+ {
+ if (options.Fields.Contains(ItemFields.BasicSyncInfo) ||
+ options.Fields.Contains(ItemFields.SyncInfo))
+ {
+ var syncProgress = GetSyncedItemProgress(options);
+
+ foreach (var tuple in tuples)
+ {
+ var item = tuple.Item1;
+
+ FillSyncInfo(tuple.Item2, item, options, user, syncProgress);
+ }
+ }
+ }
+
+ private void FillSyncInfo(IHasSyncInfo dto, BaseItem item, DtoOptions options, User user, Dictionary<string, SyncedItemProgress> syncProgress)
+ {
+ var hasFullSyncInfo = options.Fields.Contains(ItemFields.SyncInfo);
+
+ if (!options.Fields.Contains(ItemFields.BasicSyncInfo) &&
+ !hasFullSyncInfo)
+ {
+ return;
+ }
+
+ if (dto.SupportsSync ?? false)
+ {
+ SyncedItemProgress syncStatus;
+ if (syncProgress.TryGetValue(dto.Id, out syncStatus))
+ {
+ if (syncStatus.Status == SyncJobItemStatus.Synced)
+ {
+ dto.SyncPercent = 100;
+ }
+ else
+ {
+ dto.SyncPercent = syncStatus.Progress;
+ }
+
+ if (hasFullSyncInfo)
+ {
+ dto.HasSyncJob = true;
+ dto.SyncStatus = syncStatus.Status;
+ }
+ }
+ }
+ }
+
+ private async Task<BaseItemDto> GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null)
+ {
+ var fields = options.Fields;
+
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ if (fields == null)
+ {
+ throw new ArgumentNullException("fields");
+ }
+
+ var dto = new BaseItemDto
+ {
+ ServerId = _appHost.SystemId
+ };
+
+ if (item.SourceType == SourceType.Channel)
+ {
+ dto.SourceType = item.SourceType.ToString();
+ }
+
+ if (fields.Contains(ItemFields.People))
+ {
+ AttachPeople(dto, item);
+ }
+
+ if (fields.Contains(ItemFields.PrimaryImageAspectRatio))
+ {
+ try
+ {
+ AttachPrimaryImageAspectRatio(dto, item);
+ }
+ catch (Exception ex)
+ {
+ // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions
+ _logger.ErrorException("Error generating PrimaryImageAspectRatio for {0}", ex, item.Name);
+ }
+ }
+
+ if (fields.Contains(ItemFields.DisplayPreferencesId))
+ {
+ dto.DisplayPreferencesId = item.DisplayPreferencesId.ToString("N");
+ }
+
+ if (user != null)
+ {
+ await AttachUserSpecificInfo(dto, item, user, options).ConfigureAwait(false);
+ }
+
+ var hasMediaSources = item as IHasMediaSources;
+ if (hasMediaSources != null)
+ {
+ if (fields.Contains(ItemFields.MediaSources))
+ {
+ if (user == null)
+ {
+ dto.MediaSources = _mediaSourceManager().GetStaticMediaSources(hasMediaSources, true).ToList();
+ }
+ else
+ {
+ dto.MediaSources = _mediaSourceManager().GetStaticMediaSources(hasMediaSources, true, user).ToList();
+ }
+ }
+ }
+
+ if (fields.Contains(ItemFields.Studios))
+ {
+ AttachStudios(dto, item);
+ }
+
+ AttachBasicFields(dto, item, owner, options);
+
+ var collectionFolder = item as ICollectionFolder;
+ if (collectionFolder != null)
+ {
+ dto.OriginalCollectionType = collectionFolder.CollectionType;
+
+ dto.CollectionType = user == null ?
+ collectionFolder.CollectionType :
+ collectionFolder.GetViewType(user);
+ }
+
+ if (fields.Contains(ItemFields.CanDelete))
+ {
+ dto.CanDelete = user == null
+ ? item.CanDelete()
+ : item.CanDelete(user);
+ }
+
+ if (fields.Contains(ItemFields.CanDownload))
+ {
+ dto.CanDownload = user == null
+ ? item.CanDownload()
+ : item.CanDownload(user);
+ }
+
+ if (fields.Contains(ItemFields.Etag))
+ {
+ dto.Etag = item.GetEtag(user);
+ }
+
+ if (item is ILiveTvRecording)
+ {
+ _livetvManager().AddInfoToRecordingDto(item, dto, user);
+ }
+
+ return dto;
+ }
+
+ public BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem> taggedItems, Dictionary<string, SyncedItemProgress> syncProgress, User user = null)
+ {
+ var dto = GetBaseItemDtoInternal(item, options, user).Result;
+
+ if (taggedItems != null && options.Fields.Contains(ItemFields.ItemCounts))
+ {
+ SetItemByNameInfo(item, dto, taggedItems, user);
+ }
+
+ FillSyncInfo(dto, item, options, user, syncProgress);
+
+ return dto;
+ }
+
+ private void SetItemByNameInfo(BaseItem item, BaseItemDto dto, List<BaseItem> taggedItems, User user = null)
+ {
+ if (item is MusicArtist)
+ {
+ dto.AlbumCount = taggedItems.Count(i => i is MusicAlbum);
+ dto.MusicVideoCount = taggedItems.Count(i => i is MusicVideo);
+ dto.SongCount = taggedItems.Count(i => i is Audio);
+ }
+ else if (item is MusicGenre)
+ {
+ dto.ArtistCount = taggedItems.Count(i => i is MusicArtist);
+ dto.AlbumCount = taggedItems.Count(i => i is MusicAlbum);
+ dto.MusicVideoCount = taggedItems.Count(i => i is MusicVideo);
+ dto.SongCount = taggedItems.Count(i => i is Audio);
+ }
+ else if (item is GameGenre)
+ {
+ dto.GameCount = taggedItems.Count(i => i is Game);
+ }
+ else
+ {
+ // This populates them all and covers Genre, Person, Studio, Year
+
+ dto.ArtistCount = taggedItems.Count(i => i is MusicArtist);
+ dto.AlbumCount = taggedItems.Count(i => i is MusicAlbum);
+ dto.EpisodeCount = taggedItems.Count(i => i is Episode);
+ dto.GameCount = taggedItems.Count(i => i is Game);
+ dto.MovieCount = taggedItems.Count(i => i is Movie);
+ dto.TrailerCount = taggedItems.Count(i => i is Trailer);
+ dto.MusicVideoCount = taggedItems.Count(i => i is MusicVideo);
+ dto.SeriesCount = taggedItems.Count(i => i is Series);
+ dto.ProgramCount = taggedItems.Count(i => i is LiveTvProgram);
+ dto.SongCount = taggedItems.Count(i => i is Audio);
+ }
+
+ dto.ChildCount = taggedItems.Count;
+ }
+
+ /// <summary>
+ /// Attaches the user specific info.
+ /// </summary>
+ private async Task AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions dtoOptions)
+ {
+ var fields = dtoOptions.Fields;
+
+ if (item.IsFolder)
+ {
+ var folder = (Folder)item;
+
+ if (dtoOptions.EnableUserData)
+ {
+ dto.UserData = await _userDataRepository.GetUserDataDto(item, dto, user, dtoOptions.Fields).ConfigureAwait(false);
+ }
+
+ if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
+ {
+ // For these types we can try to optimize and assume these values will be equal
+ if (item is MusicAlbum || item is Season)
+ {
+ dto.ChildCount = dto.RecursiveItemCount;
+ }
+
+ if (dtoOptions.Fields.Contains(ItemFields.ChildCount))
+ {
+ dto.ChildCount = dto.ChildCount ?? GetChildCount(folder, user);
+ }
+ }
+
+ if (fields.Contains(ItemFields.CumulativeRunTimeTicks))
+ {
+ dto.CumulativeRunTimeTicks = item.RunTimeTicks;
+ }
+
+ if (fields.Contains(ItemFields.DateLastMediaAdded))
+ {
+ dto.DateLastMediaAdded = folder.DateLastMediaAdded;
+ }
+ }
+
+ else
+ {
+ if (dtoOptions.EnableUserData)
+ {
+ dto.UserData = await _userDataRepository.GetUserDataDto(item, user).ConfigureAwait(false);
+ }
+ }
+
+ dto.PlayAccess = item.GetPlayAccess(user);
+
+ if (fields.Contains(ItemFields.BasicSyncInfo) || fields.Contains(ItemFields.SyncInfo))
+ {
+ var userCanSync = user != null && user.Policy.EnableSync;
+ if (userCanSync && _syncManager.SupportsSync(item))
+ {
+ dto.SupportsSync = true;
+ }
+ }
+
+ if (fields.Contains(ItemFields.SeasonUserData))
+ {
+ var episode = item as Episode;
+
+ if (episode != null)
+ {
+ var season = episode.Season;
+
+ if (season != null)
+ {
+ dto.SeasonUserData = await _userDataRepository.GetUserDataDto(season, user).ConfigureAwait(false);
+ }
+ }
+ }
+
+ var userView = item as UserView;
+ if (userView != null)
+ {
+ dto.HasDynamicCategories = userView.ContainsDynamicCategories(user);
+ }
+
+ var collectionFolder = item as ICollectionFolder;
+ if (collectionFolder != null)
+ {
+ dto.HasDynamicCategories = false;
+ }
+ }
+
+ private int GetChildCount(Folder folder, User user)
+ {
+ // Right now this is too slow to calculate for top level folders on a per-user basis
+ // Just return something so that apps that are expecting a value won't think the folders are empty
+ if (folder is ICollectionFolder || folder is UserView)
+ {
+ return new Random().Next(1, 10);
+ }
+
+ return folder.GetChildCount(user);
+ }
+
+ /// <summary>
+ /// Gets client-side Id of a server-side BaseItem
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ public string GetDtoId(BaseItem item)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ return item.Id.ToString("N");
+ }
+
+ /// <summary>
+ /// Converts a UserItemData to a DTOUserItemData
+ /// </summary>
+ /// <param name="data">The data.</param>
+ /// <returns>DtoUserItemData.</returns>
+ /// <exception cref="System.ArgumentNullException"></exception>
+ public UserItemDataDto GetUserItemDataDto(UserItemData data)
+ {
+ if (data == null)
+ {
+ throw new ArgumentNullException("data");
+ }
+
+ return new UserItemDataDto
+ {
+ IsFavorite = data.IsFavorite,
+ Likes = data.Likes,
+ PlaybackPositionTicks = data.PlaybackPositionTicks,
+ PlayCount = data.PlayCount,
+ Rating = data.Rating,
+ Played = data.Played,
+ LastPlayedDate = data.LastPlayedDate,
+ Key = data.Key
+ };
+ }
+ private void SetBookProperties(BaseItemDto dto, Book item)
+ {
+ dto.SeriesName = item.SeriesName;
+ }
+ private void SetPhotoProperties(BaseItemDto dto, Photo item)
+ {
+ dto.Width = item.Width;
+ dto.Height = item.Height;
+ dto.CameraMake = item.CameraMake;
+ dto.CameraModel = item.CameraModel;
+ dto.Software = item.Software;
+ dto.ExposureTime = item.ExposureTime;
+ dto.FocalLength = item.FocalLength;
+ dto.ImageOrientation = item.Orientation;
+ dto.Aperture = item.Aperture;
+ dto.ShutterSpeed = item.ShutterSpeed;
+
+ dto.Latitude = item.Latitude;
+ dto.Longitude = item.Longitude;
+ dto.Altitude = item.Altitude;
+ dto.IsoSpeedRating = item.IsoSpeedRating;
+
+ var album = item.AlbumEntity;
+
+ if (album != null)
+ {
+ dto.Album = album.Name;
+ dto.AlbumId = album.Id.ToString("N");
+ }
+ }
+
+ private void SetMusicVideoProperties(BaseItemDto dto, MusicVideo item)
+ {
+ if (!string.IsNullOrEmpty(item.Album))
+ {
+ var parentAlbum = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+ Name = item.Album
+
+ }).FirstOrDefault();
+
+ if (parentAlbum != null)
+ {
+ dto.AlbumId = GetDtoId(parentAlbum);
+ }
+ }
+
+ dto.Album = item.Album;
+ }
+
+ private void SetGameProperties(BaseItemDto dto, Game item)
+ {
+ dto.Players = item.PlayersSupported;
+ dto.GameSystem = item.GameSystem;
+ dto.MultiPartGameFiles = item.MultiPartGameFiles;
+ }
+
+ private void SetGameSystemProperties(BaseItemDto dto, GameSystem item)
+ {
+ dto.GameSystem = item.GameSystemName;
+ }
+
+ private List<string> GetImageTags(BaseItem item, List<ItemImageInfo> images)
+ {
+ return images
+ .Select(p => GetImageCacheTag(item, p))
+ .Where(i => i != null)
+ .ToList();
+ }
+
+ private string GetImageCacheTag(BaseItem item, ImageType type)
+ {
+ try
+ {
+ return _imageProcessor.GetImageCacheTag(item, type);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting {0} image info", ex, type);
+ return null;
+ }
+ }
+
+ private string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+ {
+ try
+ {
+ return _imageProcessor.GetImageCacheTag(item, image);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting {0} image info for {1}", ex, image.Type, image.Path);
+ return null;
+ }
+ }
+
+ /// <summary>
+ /// Attaches People DTO's to a DTOBaseItem
+ /// </summary>
+ /// <param name="dto">The dto.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>Task.</returns>
+ private void AttachPeople(BaseItemDto dto, BaseItem item)
+ {
+ // Ordering by person type to ensure actors and artists are at the front.
+ // This is taking advantage of the fact that they both begin with A
+ // This should be improved in the future
+ var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
+ .ThenBy(i =>
+ {
+ if (i.IsType(PersonType.Actor))
+ {
+ return 0;
+ }
+ if (i.IsType(PersonType.GuestStar))
+ {
+ return 1;
+ }
+ if (i.IsType(PersonType.Director))
+ {
+ return 2;
+ }
+ if (i.IsType(PersonType.Writer))
+ {
+ return 3;
+ }
+ if (i.IsType(PersonType.Producer))
+ {
+ return 4;
+ }
+ if (i.IsType(PersonType.Composer))
+ {
+ return 4;
+ }
+
+ return 10;
+ })
+ .ToList();
+
+ var list = new List<BaseItemPerson>();
+
+ var dictionary = people.Select(p => p.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase).Select(c =>
+ {
+ try
+ {
+ return _libraryManager.GetPerson(c);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting person {0}", ex, c);
+ return null;
+ }
+
+ }).Where(i => i != null)
+ .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 0; i < people.Count; i++)
+ {
+ var person = people[i];
+
+ var baseItemPerson = new BaseItemPerson
+ {
+ Name = person.Name,
+ Role = person.Role,
+ Type = person.Type
+ };
+
+ Person entity;
+
+ if (dictionary.TryGetValue(person.Name, out entity))
+ {
+ baseItemPerson.PrimaryImageTag = GetImageCacheTag(entity, ImageType.Primary);
+ baseItemPerson.Id = entity.Id.ToString("N");
+ list.Add(baseItemPerson);
+ }
+ }
+
+ dto.People = list.ToArray();
+ }
+
+ /// <summary>
+ /// Attaches the studios.
+ /// </summary>
+ /// <param name="dto">The dto.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>Task.</returns>
+ private void AttachStudios(BaseItemDto dto, BaseItem item)
+ {
+ var studios = item.Studios.ToList();
+
+ dto.Studios = new StudioDto[studios.Count];
+
+ var dictionary = studios.Distinct(StringComparer.OrdinalIgnoreCase).Select(name =>
+ {
+ try
+ {
+ return _libraryManager.GetStudio(name);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error getting studio {0}", ex, name);
+ return null;
+ }
+ })
+ .Where(i => i != null)
+ .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 0; i < studios.Count; i++)
+ {
+ var studio = studios[i];
+
+ var studioDto = new StudioDto
+ {
+ Name = studio
+ };
+
+ Studio entity;
+
+ if (dictionary.TryGetValue(studio, out entity))
+ {
+ studioDto.Id = entity.Id.ToString("N");
+ studioDto.PrimaryImageTag = GetImageCacheTag(entity, ImageType.Primary);
+ }
+
+ dto.Studios[i] = studioDto;
+ }
+ }
+
+ /// <summary>
+ /// Gets the chapter info dto.
+ /// </summary>
+ /// <param name="chapterInfo">The chapter info.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>ChapterInfoDto.</returns>
+ private ChapterInfoDto GetChapterInfoDto(ChapterInfo chapterInfo, BaseItem item)
+ {
+ var dto = new ChapterInfoDto
+ {
+ Name = chapterInfo.Name,
+ StartPositionTicks = chapterInfo.StartPositionTicks
+ };
+
+ if (!string.IsNullOrEmpty(chapterInfo.ImagePath))
+ {
+ dto.ImageTag = GetImageCacheTag(item, new ItemImageInfo
+ {
+ Path = chapterInfo.ImagePath,
+ Type = ImageType.Chapter,
+ DateModified = chapterInfo.ImageDateModified
+ });
+ }
+
+ return dto;
+ }
+
+ public List<ChapterInfoDto> GetChapterInfoDtos(BaseItem item)
+ {
+ return _itemRepo.GetChapters(item.Id)
+ .Select(c => GetChapterInfoDto(c, item))
+ .ToList();
+ }
+
+ /// <summary>
+ /// Sets simple property values on a DTOBaseItem
+ /// </summary>
+ /// <param name="dto">The dto.</param>
+ /// <param name="item">The item.</param>
+ /// <param name="owner">The owner.</param>
+ /// <param name="options">The options.</param>
+ private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem owner, DtoOptions options)
+ {
+ var fields = options.Fields;
+
+ if (fields.Contains(ItemFields.DateCreated))
+ {
+ dto.DateCreated = item.DateCreated;
+ }
+
+ if (fields.Contains(ItemFields.DisplayMediaType))
+ {
+ dto.DisplayMediaType = item.DisplayMediaType;
+ }
+
+ if (fields.Contains(ItemFields.Settings))
+ {
+ dto.LockedFields = item.LockedFields;
+ dto.LockData = item.IsLocked;
+ dto.ForcedSortName = item.ForcedSortName;
+ }
+ dto.Container = item.Container;
+
+ var hasBudget = item as IHasBudget;
+ if (hasBudget != null)
+ {
+ if (fields.Contains(ItemFields.Budget))
+ {
+ dto.Budget = hasBudget.Budget;
+ }
+
+ if (fields.Contains(ItemFields.Revenue))
+ {
+ dto.Revenue = hasBudget.Revenue;
+ }
+ }
+
+ dto.EndDate = item.EndDate;
+
+ if (fields.Contains(ItemFields.HomePageUrl))
+ {
+ dto.HomePageUrl = item.HomePageUrl;
+ }
+
+ if (fields.Contains(ItemFields.ExternalUrls))
+ {
+ dto.ExternalUrls = _providerManager.GetExternalUrls(item).ToArray();
+ }
+
+ if (fields.Contains(ItemFields.Tags))
+ {
+ dto.Tags = item.Tags;
+ }
+
+ if (fields.Contains(ItemFields.Keywords))
+ {
+ dto.Keywords = item.Keywords;
+ }
+
+ var hasAspectRatio = item as IHasAspectRatio;
+ if (hasAspectRatio != null)
+ {
+ dto.AspectRatio = hasAspectRatio.AspectRatio;
+ }
+
+ if (fields.Contains(ItemFields.Metascore))
+ {
+ var hasMetascore = item as IHasMetascore;
+ if (hasMetascore != null)
+ {
+ dto.Metascore = hasMetascore.Metascore;
+ }
+ }
+
+ if (fields.Contains(ItemFields.AwardSummary))
+ {
+ var hasAwards = item as IHasAwards;
+ if (hasAwards != null)
+ {
+ dto.AwardSummary = hasAwards.AwardSummary;
+ }
+ }
+
+ var backdropLimit = options.GetImageLimit(ImageType.Backdrop);
+ if (backdropLimit > 0)
+ {
+ dto.BackdropImageTags = GetImageTags(item, item.GetImages(ImageType.Backdrop).Take(backdropLimit).ToList());
+ }
+
+ if (fields.Contains(ItemFields.ScreenshotImageTags))
+ {
+ var screenshotLimit = options.GetImageLimit(ImageType.Screenshot);
+ if (screenshotLimit > 0)
+ {
+ dto.ScreenshotImageTags = GetImageTags(item, item.GetImages(ImageType.Screenshot).Take(screenshotLimit).ToList());
+ }
+ }
+
+ if (fields.Contains(ItemFields.Genres))
+ {
+ dto.Genres = item.Genres;
+ }
+
+ if (options.EnableImages)
+ {
+ dto.ImageTags = new Dictionary<ImageType, string>();
+
+ // Prevent implicitly captured closure
+ var currentItem = item;
+ foreach (var image in currentItem.ImageInfos.Where(i => !currentItem.AllowsMultipleImages(i.Type))
+ .ToList())
+ {
+ if (options.GetImageLimit(image.Type) > 0)
+ {
+ var tag = GetImageCacheTag(item, image);
+
+ if (tag != null)
+ {
+ dto.ImageTags[image.Type] = tag;
+ }
+ }
+ }
+ }
+
+ dto.Id = GetDtoId(item);
+ dto.IndexNumber = item.IndexNumber;
+ dto.ParentIndexNumber = item.ParentIndexNumber;
+
+ if (item.IsFolder)
+ {
+ dto.IsFolder = true;
+ }
+ else if (item is IHasMediaSources)
+ {
+ dto.IsFolder = false;
+ }
+
+ dto.MediaType = item.MediaType;
+ dto.LocationType = item.LocationType;
+ if (item.IsHD.HasValue && item.IsHD.Value)
+ {
+ dto.IsHD = item.IsHD;
+ }
+ dto.Audio = item.Audio;
+
+ if (fields.Contains(ItemFields.Settings))
+ {
+ dto.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode;
+ dto.PreferredMetadataLanguage = item.PreferredMetadataLanguage;
+ }
+
+ dto.CriticRating = item.CriticRating;
+
+ if (fields.Contains(ItemFields.CriticRatingSummary))
+ {
+ dto.CriticRatingSummary = item.CriticRatingSummary;
+ }
+
+ var hasTrailers = item as IHasTrailers;
+ if (hasTrailers != null)
+ {
+ dto.LocalTrailerCount = hasTrailers.GetTrailerIds().Count;
+ }
+
+ var hasDisplayOrder = item as IHasDisplayOrder;
+ if (hasDisplayOrder != null)
+ {
+ dto.DisplayOrder = hasDisplayOrder.DisplayOrder;
+ }
+
+ var userView = item as UserView;
+ if (userView != null)
+ {
+ dto.CollectionType = userView.ViewType;
+ }
+
+ if (fields.Contains(ItemFields.RemoteTrailers))
+ {
+ dto.RemoteTrailers = hasTrailers != null ?
+ hasTrailers.RemoteTrailers :
+ new List<MediaUrl>();
+ }
+
+ dto.Name = item.Name;
+ dto.OfficialRating = item.OfficialRating;
+
+ if (fields.Contains(ItemFields.Overview))
+ {
+ dto.Overview = item.Overview;
+ }
+
+ if (fields.Contains(ItemFields.OriginalTitle))
+ {
+ dto.OriginalTitle = item.OriginalTitle;
+ }
+
+ if (fields.Contains(ItemFields.ShortOverview))
+ {
+ dto.ShortOverview = item.ShortOverview;
+ }
+
+ if (fields.Contains(ItemFields.ParentId))
+ {
+ var displayParentId = item.DisplayParentId;
+ if (displayParentId.HasValue)
+ {
+ dto.ParentId = displayParentId.Value.ToString("N");
+ }
+ }
+
+ AddInheritedImages(dto, item, options, owner);
+
+ if (fields.Contains(ItemFields.Path))
+ {
+ dto.Path = GetMappedPath(item);
+ }
+
+ dto.PremiereDate = item.PremiereDate;
+ dto.ProductionYear = item.ProductionYear;
+
+ if (fields.Contains(ItemFields.ProviderIds))
+ {
+ dto.ProviderIds = item.ProviderIds;
+ }
+
+ dto.RunTimeTicks = item.RunTimeTicks;
+
+ if (fields.Contains(ItemFields.SortName))
+ {
+ dto.SortName = item.SortName;
+ }
+
+ if (fields.Contains(ItemFields.CustomRating))
+ {
+ dto.CustomRating = item.CustomRating;
+ }
+
+ if (fields.Contains(ItemFields.Taglines))
+ {
+ if (!string.IsNullOrWhiteSpace(item.Tagline))
+ {
+ dto.Taglines = new List<string> { item.Tagline };
+ }
+
+ if (dto.Taglines == null)
+ {
+ dto.Taglines = new List<string>();
+ }
+ }
+
+ dto.Type = item.GetClientTypeName();
+ dto.CommunityRating = item.CommunityRating;
+
+ if (fields.Contains(ItemFields.VoteCount))
+ {
+ dto.VoteCount = item.VoteCount;
+ }
+
+ //if (item.IsFolder)
+ //{
+ // var folder = (Folder)item;
+
+ // if (fields.Contains(ItemFields.IndexOptions))
+ // {
+ // dto.IndexOptions = folder.IndexByOptionStrings.ToArray();
+ // }
+ //}
+
+ var supportsPlaceHolders = item as ISupportsPlaceHolders;
+ if (supportsPlaceHolders != null)
+ {
+ dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder;
+ }
+
+ // Add audio info
+ var audio = item as Audio;
+ if (audio != null)
+ {
+ dto.Album = audio.Album;
+ dto.ExtraType = audio.ExtraType;
+
+ var albumParent = audio.AlbumEntity;
+
+ if (albumParent != null)
+ {
+ dto.AlbumId = GetDtoId(albumParent);
+
+ dto.AlbumPrimaryImageTag = GetImageCacheTag(albumParent, ImageType.Primary);
+ }
+
+ //if (fields.Contains(ItemFields.MediaSourceCount))
+ //{
+ // Songs always have one
+ //}
+ }
+
+ var hasArtist = item as IHasArtist;
+ if (hasArtist != null)
+ {
+ dto.Artists = hasArtist.Artists;
+
+ //var artistItems = _libraryManager.GetArtists(new InternalItemsQuery
+ //{
+ // EnableTotalRecordCount = false,
+ // ItemIds = new[] { item.Id.ToString("N") }
+ //});
+
+ //dto.ArtistItems = artistItems.Items
+ // .Select(i =>
+ // {
+ // var artist = i.Item1;
+ // return new NameIdPair
+ // {
+ // Name = artist.Name,
+ // Id = artist.Id.ToString("N")
+ // };
+ // })
+ // .ToList();
+
+ // Include artists that are not in the database yet, e.g., just added via metadata editor
+ //var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
+ dto.ArtistItems = new List<NameIdPair>();
+ dto.ArtistItems.AddRange(hasArtist.Artists
+ //.Except(foundArtists, new DistinctNameComparer())
+ .Select(i =>
+ {
+ // This should not be necessary but we're seeing some cases of it
+ if (string.IsNullOrWhiteSpace(i))
+ {
+ return null;
+ }
+
+ var artist = _libraryManager.GetArtist(i);
+ if (artist != null)
+ {
+ return new NameIdPair
+ {
+ Name = artist.Name,
+ Id = artist.Id.ToString("N")
+ };
+ }
+
+ return null;
+
+ }).Where(i => i != null));
+ }
+
+ var hasAlbumArtist = item as IHasAlbumArtist;
+ if (hasAlbumArtist != null)
+ {
+ dto.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+
+ //var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery
+ //{
+ // EnableTotalRecordCount = false,
+ // ItemIds = new[] { item.Id.ToString("N") }
+ //});
+
+ //dto.AlbumArtists = artistItems.Items
+ // .Select(i =>
+ // {
+ // var artist = i.Item1;
+ // return new NameIdPair
+ // {
+ // Name = artist.Name,
+ // Id = artist.Id.ToString("N")
+ // };
+ // })
+ // .ToList();
+
+ dto.AlbumArtists = new List<NameIdPair>();
+ dto.AlbumArtists.AddRange(hasAlbumArtist.AlbumArtists
+ //.Except(foundArtists, new DistinctNameComparer())
+ .Select(i =>
+ {
+ // This should not be necessary but we're seeing some cases of it
+ if (string.IsNullOrWhiteSpace(i))
+ {
+ return null;
+ }
+
+ var artist = _libraryManager.GetArtist(i);
+ if (artist != null)
+ {
+ return new NameIdPair
+ {
+ Name = artist.Name,
+ Id = artist.Id.ToString("N")
+ };
+ }
+
+ return null;
+
+ }).Where(i => i != null));
+ }
+
+ // Add video info
+ var video = item as Video;
+ if (video != null)
+ {
+ dto.VideoType = video.VideoType;
+ dto.Video3DFormat = video.Video3DFormat;
+ dto.IsoType = video.IsoType;
+
+ if (video.HasSubtitles)
+ {
+ dto.HasSubtitles = video.HasSubtitles;
+ }
+
+ if (video.AdditionalParts.Count != 0)
+ {
+ dto.PartCount = video.AdditionalParts.Count + 1;
+ }
+
+ if (fields.Contains(ItemFields.MediaSourceCount))
+ {
+ var mediaSourceCount = video.MediaSourceCount;
+ if (mediaSourceCount != 1)
+ {
+ dto.MediaSourceCount = mediaSourceCount;
+ }
+ }
+
+ if (fields.Contains(ItemFields.Chapters))
+ {
+ dto.Chapters = GetChapterInfoDtos(item);
+ }
+
+ dto.ExtraType = video.ExtraType;
+ }
+
+ if (fields.Contains(ItemFields.MediaStreams))
+ {
+ // Add VideoInfo
+ var iHasMediaSources = item as IHasMediaSources;
+
+ if (iHasMediaSources != null)
+ {
+ List<MediaStream> mediaStreams;
+
+ if (dto.MediaSources != null && dto.MediaSources.Count > 0)
+ {
+ mediaStreams = dto.MediaSources.Where(i => new Guid(i.Id) == item.Id)
+ .SelectMany(i => i.MediaStreams)
+ .ToList();
+ }
+ else
+ {
+ mediaStreams = _mediaSourceManager().GetStaticMediaSources(iHasMediaSources, true).First().MediaStreams;
+ }
+
+ dto.MediaStreams = mediaStreams;
+ }
+ }
+
+ var hasSpecialFeatures = item as IHasSpecialFeatures;
+ if (hasSpecialFeatures != null)
+ {
+ var specialFeatureCount = hasSpecialFeatures.SpecialFeatureIds.Count;
+
+ if (specialFeatureCount > 0)
+ {
+ dto.SpecialFeatureCount = specialFeatureCount;
+ }
+ }
+
+ // Add EpisodeInfo
+ var episode = item as Episode;
+ if (episode != null)
+ {
+ dto.IndexNumberEnd = episode.IndexNumberEnd;
+ dto.SeriesName = episode.SeriesName;
+
+ if (fields.Contains(ItemFields.AlternateEpisodeNumbers))
+ {
+ dto.DvdSeasonNumber = episode.DvdSeasonNumber;
+ dto.DvdEpisodeNumber = episode.DvdEpisodeNumber;
+ dto.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber;
+ }
+
+ if (fields.Contains(ItemFields.SpecialEpisodeNumbers))
+ {
+ dto.AirsAfterSeasonNumber = episode.AirsAfterSeasonNumber;
+ dto.AirsBeforeEpisodeNumber = episode.AirsBeforeEpisodeNumber;
+ dto.AirsBeforeSeasonNumber = episode.AirsBeforeSeasonNumber;
+ }
+
+ var seasonId = episode.SeasonId;
+ if (seasonId.HasValue)
+ {
+ dto.SeasonId = seasonId.Value.ToString("N");
+ }
+
+ dto.SeasonName = episode.SeasonName;
+
+ var seriesId = episode.SeriesId;
+ if (seriesId.HasValue)
+ {
+ dto.SeriesId = seriesId.Value.ToString("N");
+ }
+
+ Series episodeSeries = null;
+
+ if (fields.Contains(ItemFields.SeriesGenres))
+ {
+ episodeSeries = episodeSeries ?? episode.Series;
+ if (episodeSeries != null)
+ {
+ dto.SeriesGenres = episodeSeries.Genres.ToList();
+ }
+ }
+
+ //if (fields.Contains(ItemFields.SeriesPrimaryImage))
+ {
+ episodeSeries = episodeSeries ?? episode.Series;
+ if (episodeSeries != null)
+ {
+ dto.SeriesPrimaryImageTag = GetImageCacheTag(episodeSeries, ImageType.Primary);
+ }
+ }
+
+ if (fields.Contains(ItemFields.SeriesStudio))
+ {
+ episodeSeries = episodeSeries ?? episode.Series;
+ if (episodeSeries != null)
+ {
+ dto.SeriesStudio = episodeSeries.Studios.FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(dto.SeriesStudio))
+ {
+ try
+ {
+ var studio = _libraryManager.GetStudio(dto.SeriesStudio);
+
+ if (studio != null)
+ {
+ dto.SeriesStudioInfo = new StudioDto
+ {
+ Name = dto.SeriesStudio,
+ Id = studio.Id.ToString("N"),
+ PrimaryImageTag = GetImageCacheTag(studio, ImageType.Primary)
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+
+ }
+ }
+ }
+ }
+ }
+
+ // Add SeriesInfo
+ var series = item as Series;
+ if (series != null)
+ {
+ dto.AirDays = series.AirDays;
+ dto.AirTime = series.AirTime;
+ dto.SeriesStatus = series.Status;
+
+ dto.AnimeSeriesIndex = series.AnimeSeriesIndex;
+ }
+
+ // Add SeasonInfo
+ var season = item as Season;
+ if (season != null)
+ {
+ dto.SeriesName = season.SeriesName;
+
+ var seriesId = season.SeriesId;
+ if (seriesId.HasValue)
+ {
+ dto.SeriesId = seriesId.Value.ToString("N");
+ }
+
+ series = null;
+
+ if (fields.Contains(ItemFields.SeriesStudio))
+ {
+ series = series ?? season.Series;
+ if (series != null)
+ {
+ dto.SeriesStudio = series.Studios.FirstOrDefault();
+ }
+ }
+
+ if (fields.Contains(ItemFields.SeriesPrimaryImage))
+ {
+ series = series ?? season.Series;
+ if (series != null)
+ {
+ dto.SeriesPrimaryImageTag = GetImageCacheTag(series, ImageType.Primary);
+ }
+ }
+ }
+
+ var game = item as Game;
+
+ if (game != null)
+ {
+ SetGameProperties(dto, game);
+ }
+
+ var gameSystem = item as GameSystem;
+
+ if (gameSystem != null)
+ {
+ SetGameSystemProperties(dto, gameSystem);
+ }
+
+ var musicVideo = item as MusicVideo;
+ if (musicVideo != null)
+ {
+ SetMusicVideoProperties(dto, musicVideo);
+ }
+
+ var book = item as Book;
+ if (book != null)
+ {
+ SetBookProperties(dto, book);
+ }
+
+ if (item.ProductionLocations.Count > 0 || item is Movie)
+ {
+ dto.ProductionLocations = item.ProductionLocations.ToArray();
+ }
+
+ var photo = item as Photo;
+ if (photo != null)
+ {
+ SetPhotoProperties(dto, photo);
+ }
+
+ dto.ChannelId = item.ChannelId;
+
+ if (item.SourceType == SourceType.Channel && !string.IsNullOrWhiteSpace(item.ChannelId))
+ {
+ var channel = _libraryManager.GetItemById(item.ChannelId);
+ if (channel != null)
+ {
+ dto.ChannelName = channel.Name;
+ }
+ }
+ }
+
+ private BaseItem GetImageDisplayParent(BaseItem item)
+ {
+ var musicAlbum = item as MusicAlbum;
+ if (musicAlbum != null)
+ {
+ var artist = musicAlbum.MusicArtist;
+ if (artist != null)
+ {
+ return artist;
+ }
+ }
+ return item.GetParent();
+ }
+
+ private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem owner)
+ {
+ if (!item.SupportsInheritedParentImages)
+ {
+ return;
+ }
+
+ var logoLimit = options.GetImageLimit(ImageType.Logo);
+ var artLimit = options.GetImageLimit(ImageType.Art);
+ var thumbLimit = options.GetImageLimit(ImageType.Thumb);
+ var backdropLimit = options.GetImageLimit(ImageType.Backdrop);
+
+ // For now. Emby apps are not using this
+ artLimit = 0;
+
+ if (logoLimit == 0 && artLimit == 0 && thumbLimit == 0 && backdropLimit == 0)
+ {
+ return;
+ }
+
+ BaseItem parent = null;
+ var isFirst = true;
+
+ while (((!dto.HasLogo && logoLimit > 0) || (!dto.HasArtImage && artLimit > 0) || (!dto.HasThumb && thumbLimit > 0) || parent is Series) &&
+ (parent = parent ?? (isFirst ? GetImageDisplayParent(item) ?? owner : parent)) != null)
+ {
+ if (parent == null)
+ {
+ break;
+ }
+
+ var allImages = parent.ImageInfos;
+
+ if (logoLimit > 0 && !dto.HasLogo && dto.ParentLogoItemId == null)
+ {
+ var image = allImages.FirstOrDefault(i => i.Type == ImageType.Logo);
+
+ if (image != null)
+ {
+ dto.ParentLogoItemId = GetDtoId(parent);
+ dto.ParentLogoImageTag = GetImageCacheTag(parent, image);
+ }
+ }
+ if (artLimit > 0 && !dto.HasArtImage && dto.ParentArtItemId == null)
+ {
+ var image = allImages.FirstOrDefault(i => i.Type == ImageType.Art);
+
+ if (image != null)
+ {
+ dto.ParentArtItemId = GetDtoId(parent);
+ dto.ParentArtImageTag = GetImageCacheTag(parent, image);
+ }
+ }
+ if (thumbLimit > 0 && !dto.HasThumb && (dto.ParentThumbItemId == null || parent is Series))
+ {
+ var image = allImages.FirstOrDefault(i => i.Type == ImageType.Thumb);
+
+ if (image != null)
+ {
+ dto.ParentThumbItemId = GetDtoId(parent);
+ dto.ParentThumbImageTag = GetImageCacheTag(parent, image);
+ }
+ }
+ if (backdropLimit > 0 && !dto.HasBackdrop)
+ {
+ var images = allImages.Where(i => i.Type == ImageType.Backdrop).Take(backdropLimit).ToList();
+
+ if (images.Count > 0)
+ {
+ dto.ParentBackdropItemId = GetDtoId(parent);
+ dto.ParentBackdropImageTags = GetImageTags(parent, images);
+ }
+ }
+
+ isFirst = false;
+
+ if (!parent.SupportsInheritedParentImages)
+ {
+ break;
+ }
+
+ parent = GetImageDisplayParent(parent);
+ }
+ }
+
+ private string GetMappedPath(BaseItem item)
+ {
+ var path = item.Path;
+
+ var locationType = item.LocationType;
+
+ if (locationType == LocationType.FileSystem || locationType == LocationType.Offline)
+ {
+ path = _libraryManager.GetPathAfterNetworkSubstitution(path, item);
+ }
+
+ return path;
+ }
+
+ /// <summary>
+ /// Attaches the primary image aspect ratio.
+ /// </summary>
+ /// <param name="dto">The dto.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>Task.</returns>
+ public void AttachPrimaryImageAspectRatio(IItemDto dto, IHasImages item)
+ {
+ dto.PrimaryImageAspectRatio = GetPrimaryImageAspectRatio(item);
+ }
+
+ public double? GetPrimaryImageAspectRatio(IHasImages item)
+ {
+ var imageInfo = item.GetImageInfo(ImageType.Primary, 0);
+
+ if (imageInfo == null || !imageInfo.IsLocalFile)
+ {
+ return null;
+ }
+
+ ImageSize size;
+
+ try
+ {
+ size = _imageProcessor.GetImageSize(imageInfo);
+ }
+ catch
+ {
+ //_logger.ErrorException("Failed to determine primary image aspect ratio for {0}", ex, path);
+ return null;
+ }
+
+ var supportedEnhancers = _imageProcessor.GetSupportedEnhancers(item, ImageType.Primary).ToList();
+
+ foreach (var enhancer in supportedEnhancers)
+ {
+ try
+ {
+ size = enhancer.GetEnhancedImageSize(item, ImageType.Primary, 0, size);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in image enhancer: {0}", ex, enhancer.GetType().Name);
+ }
+ }
+
+ var width = size.Width;
+ var height = size.Height;
+
+ if (width == 0 || height == 0)
+ {
+ return null;
+ }
+
+ var photo = item as Photo;
+ if (photo != null && photo.Orientation.HasValue)
+ {
+ switch (photo.Orientation.Value)
+ {
+ case ImageOrientation.LeftBottom:
+ case ImageOrientation.LeftTop:
+ case ImageOrientation.RightBottom:
+ case ImageOrientation.RightTop:
+ var temp = height;
+ height = width;
+ width = temp;
+ break;
+ }
+ }
+
+ return width / height;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
new file mode 100644
index 000000000..d773fbbf7
--- /dev/null
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -0,0 +1,435 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+ <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
+ <PropertyGroup>
+ <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
+ <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
+ <ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid>
+ <OutputType>Library</OutputType>
+ <AppDesignerFolder>Properties</AppDesignerFolder>
+ <RootNamespace>Emby.Server.Implementations</RootNamespace>
+ <AssemblyName>Emby.Server.Implementations</AssemblyName>
+ <FileAlignment>512</FileAlignment>
+ <ProjectTypeGuids>{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
+ <TargetFrameworkProfile>Profile7</TargetFrameworkProfile>
+ <TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
+ </PropertyGroup>
+ <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
+ <DebugSymbols>true</DebugSymbols>
+ <DebugType>full</DebugType>
+ <Optimize>false</Optimize>
+ <OutputPath>bin\Debug\</OutputPath>
+ <DefineConstants>DEBUG;TRACE</DefineConstants>
+ <ErrorReport>prompt</ErrorReport>
+ <WarningLevel>4</WarningLevel>
+ </PropertyGroup>
+ <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
+ <DebugType>pdbonly</DebugType>
+ <Optimize>true</Optimize>
+ <OutputPath>bin\Release\</OutputPath>
+ <DefineConstants>TRACE</DefineConstants>
+ <ErrorReport>prompt</ErrorReport>
+ <WarningLevel>4</WarningLevel>
+ </PropertyGroup>
+ <ItemGroup>
+ <Compile Include="Activity\ActivityLogEntryPoint.cs" />
+ <Compile Include="Activity\ActivityManager.cs" />
+ <Compile Include="Activity\ActivityRepository.cs" />
+ <Compile Include="Branding\BrandingConfigurationFactory.cs" />
+ <Compile Include="Browser\BrowserLauncher.cs" />
+ <Compile Include="Channels\ChannelConfigurations.cs" />
+ <Compile Include="Channels\ChannelDynamicMediaSourceProvider.cs" />
+ <Compile Include="Channels\ChannelImageProvider.cs" />
+ <Compile Include="Channels\ChannelManager.cs" />
+ <Compile Include="Channels\ChannelPostScanTask.cs" />
+ <Compile Include="Channels\RefreshChannelsScheduledTask.cs" />
+ <Compile Include="Collections\CollectionImageProvider.cs" />
+ <Compile Include="Collections\CollectionManager.cs" />
+ <Compile Include="Collections\CollectionsDynamicFolder.cs" />
+ <Compile Include="Connect\ConnectData.cs" />
+ <Compile Include="Connect\ConnectEntryPoint.cs" />
+ <Compile Include="Connect\ConnectManager.cs" />
+ <Compile Include="Connect\Responses.cs" />
+ <Compile Include="Connect\Validator.cs" />
+ <Compile Include="Data\ManagedConnection.cs" />
+ <Compile Include="Data\SqliteDisplayPreferencesRepository.cs" />
+ <Compile Include="Data\SqliteFileOrganizationRepository.cs" />
+ <Compile Include="Data\SqliteItemRepository.cs" />
+ <Compile Include="Data\SqliteUserDataRepository.cs" />
+ <Compile Include="Data\SqliteUserRepository.cs" />
+ <Compile Include="Data\TypeMapper.cs" />
+ <Compile Include="Devices\CameraUploadsDynamicFolder.cs" />
+ <Compile Include="Devices\DeviceManager.cs" />
+ <Compile Include="Devices\DeviceRepository.cs" />
+ <Compile Include="Dto\DtoService.cs" />
+ <Compile Include="EntryPoints\AutomaticRestartEntryPoint.cs" />
+ <Compile Include="EntryPoints\KeepServerAwake.cs" />
+ <Compile Include="EntryPoints\LibraryChangedNotifier.cs" />
+ <Compile Include="EntryPoints\LoadRegistrations.cs" />
+ <Compile Include="EntryPoints\RecordingNotifier.cs" />
+ <Compile Include="EntryPoints\RefreshUsersMetadata.cs" />
+ <Compile Include="EntryPoints\ServerEventNotifier.cs" />
+ <Compile Include="EntryPoints\StartupWizard.cs" />
+ <Compile Include="EntryPoints\SystemEvents.cs" />
+ <Compile Include="EntryPoints\UdpServerEntryPoint.cs" />
+ <Compile Include="EntryPoints\UsageEntryPoint.cs" />
+ <Compile Include="EntryPoints\UsageReporter.cs" />
+ <Compile Include="EntryPoints\UserDataChangeNotifier.cs" />
+ <Compile Include="FFMpeg\FFMpegInfo.cs" />
+ <Compile Include="FFMpeg\FFMpegInstallInfo.cs" />
+ <Compile Include="FFMpeg\FFMpegLoader.cs" />
+ <Compile Include="FileOrganization\EpisodeFileOrganizer.cs" />
+ <Compile Include="FileOrganization\Extensions.cs" />
+ <Compile Include="FileOrganization\FileOrganizationNotifier.cs" />
+ <Compile Include="FileOrganization\FileOrganizationService.cs" />
+ <Compile Include="FileOrganization\NameUtils.cs" />
+ <Compile Include="FileOrganization\OrganizerScheduledTask.cs" />
+ <Compile Include="FileOrganization\TvFolderOrganizer.cs" />
+ <Compile Include="HttpServer\GetSwaggerResource.cs" />
+ <Compile Include="HttpServer\HttpListenerHost.cs" />
+ <Compile Include="HttpServer\HttpResultFactory.cs" />
+ <Compile Include="HttpServer\LoggerUtils.cs" />
+ <Compile Include="HttpServer\RangeRequestWriter.cs" />
+ <Compile Include="HttpServer\ResponseFilter.cs" />
+ <Compile Include="HttpServer\SocketSharp\Extensions.cs" />
+ <Compile Include="HttpServer\SocketSharp\HttpUtility.cs" />
+ <Compile Include="HttpServer\IHttpListener.cs" />
+ <Compile Include="HttpServer\Security\AuthorizationContext.cs" />
+ <Compile Include="HttpServer\Security\AuthService.cs" />
+ <Compile Include="HttpServer\Security\SessionContext.cs" />
+ <Compile Include="HttpServer\SocketSharp\RequestMono.cs" />
+ <Compile Include="HttpServer\SocketSharp\SharpWebSocket.cs" />
+ <Compile Include="HttpServer\SocketSharp\WebSocketSharpListener.cs" />
+ <Compile Include="HttpServer\SocketSharp\WebSocketSharpRequest.cs" />
+ <Compile Include="HttpServer\SocketSharp\WebSocketSharpResponse.cs" />
+ <Compile Include="HttpServer\StreamWriter.cs" />
+ <Compile Include="HttpServer\SwaggerService.cs" />
+ <Compile Include="Images\BaseDynamicImageProvider.cs" />
+ <Compile Include="Intros\DefaultIntroProvider.cs" />
+ <Compile Include="IO\FileRefresher.cs" />
+ <Compile Include="IO\MbLinkShortcutHandler.cs" />
+ <Compile Include="IO\ThrottledStream.cs" />
+ <Compile Include="Library\CoreResolutionIgnoreRule.cs" />
+ <Compile Include="Library\LibraryManager.cs" />
+ <Compile Include="Library\LocalTrailerPostScanTask.cs" />
+ <Compile Include="Library\MediaSourceManager.cs" />
+ <Compile Include="Library\MusicManager.cs" />
+ <Compile Include="Library\PathExtensions.cs" />
+ <Compile Include="Library\ResolverHelper.cs" />
+ <Compile Include="Library\Resolvers\Audio\AudioResolver.cs" />
+ <Compile Include="Library\Resolvers\Audio\MusicAlbumResolver.cs" />
+ <Compile Include="Library\Resolvers\Audio\MusicArtistResolver.cs" />
+ <Compile Include="Library\Resolvers\BaseVideoResolver.cs" />
+ <Compile Include="Library\Resolvers\Books\BookResolver.cs" />
+ <Compile Include="Library\Resolvers\FolderResolver.cs" />
+ <Compile Include="Library\Resolvers\ItemResolver.cs" />
+ <Compile Include="Library\Resolvers\Movies\BoxSetResolver.cs" />
+ <Compile Include="Library\Resolvers\Movies\MovieResolver.cs" />
+ <Compile Include="Library\Resolvers\PhotoAlbumResolver.cs" />
+ <Compile Include="Library\Resolvers\PhotoResolver.cs" />
+ <Compile Include="Library\Resolvers\PlaylistResolver.cs" />
+ <Compile Include="Library\Resolvers\SpecialFolderResolver.cs" />
+ <Compile Include="Library\Resolvers\TV\EpisodeResolver.cs" />
+ <Compile Include="Library\Resolvers\TV\SeasonResolver.cs" />
+ <Compile Include="Library\Resolvers\TV\SeriesResolver.cs" />
+ <Compile Include="Library\Resolvers\VideoResolver.cs" />
+ <Compile Include="Library\SearchEngine.cs" />
+ <Compile Include="Library\UserDataManager.cs" />
+ <Compile Include="Library\UserManager.cs" />
+ <Compile Include="Library\UserViewManager.cs" />
+ <Compile Include="Library\Validators\ArtistsPostScanTask.cs" />
+ <Compile Include="Library\Validators\ArtistsValidator.cs" />
+ <Compile Include="Library\Validators\GameGenresPostScanTask.cs" />
+ <Compile Include="Library\Validators\GameGenresValidator.cs" />
+ <Compile Include="Library\Validators\GenresPostScanTask.cs" />
+ <Compile Include="Library\Validators\GenresValidator.cs" />
+ <Compile Include="Library\Validators\MusicGenresPostScanTask.cs" />
+ <Compile Include="Library\Validators\MusicGenresValidator.cs" />
+ <Compile Include="Library\Validators\PeopleValidator.cs" />
+ <Compile Include="Library\Validators\StudiosPostScanTask.cs" />
+ <Compile Include="Library\Validators\StudiosValidator.cs" />
+ <Compile Include="Library\Validators\YearsPostScanTask.cs" />
+ <Compile Include="LiveTv\ChannelImageProvider.cs" />
+ <Compile Include="LiveTv\EmbyTV\DirectRecorder.cs" />
+ <Compile Include="LiveTv\EmbyTV\EmbyTV.cs" />
+ <Compile Include="LiveTv\EmbyTV\EmbyTVRegistration.cs" />
+ <Compile Include="LiveTv\EmbyTV\EncodedRecorder.cs" />
+ <Compile Include="LiveTv\EmbyTV\EntryPoint.cs" />
+ <Compile Include="LiveTv\EmbyTV\IRecorder.cs" />
+ <Compile Include="LiveTv\EmbyTV\ItemDataProvider.cs" />
+ <Compile Include="LiveTv\EmbyTV\RecordingHelper.cs" />
+ <Compile Include="LiveTv\EmbyTV\SeriesTimerManager.cs" />
+ <Compile Include="LiveTv\EmbyTV\TimerManager.cs" />
+ <Compile Include="LiveTv\Listings\SchedulesDirect.cs" />
+ <Compile Include="LiveTv\Listings\XmlTvListingsProvider.cs" />
+ <Compile Include="LiveTv\LiveStreamHelper.cs" />
+ <Compile Include="LiveTv\LiveTvConfigurationFactory.cs" />
+ <Compile Include="LiveTv\LiveTvDtoService.cs" />
+ <Compile Include="LiveTv\LiveTvManager.cs" />
+ <Compile Include="LiveTv\LiveTvMediaSourceProvider.cs" />
+ <Compile Include="LiveTv\ProgramImageProvider.cs" />
+ <Compile Include="LiveTv\RecordingImageProvider.cs" />
+ <Compile Include="LiveTv\RefreshChannelsScheduledTask.cs" />
+ <Compile Include="LiveTv\TunerHosts\BaseTunerHost.cs" />
+ <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunDiscovery.cs" />
+ <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunHost.cs" />
+ <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunLiveStream.cs" />
+ <Compile Include="LiveTv\TunerHosts\M3uParser.cs" />
+ <Compile Include="LiveTv\TunerHosts\M3UTunerHost.cs" />
+ <Compile Include="LiveTv\TunerHosts\MulticastStream.cs" />
+ <Compile Include="LiveTv\TunerHosts\QueueStream.cs" />
+ <Compile Include="Localization\LocalizationManager.cs" />
+ <Compile Include="MediaEncoder\EncodingManager.cs" />
+ <Compile Include="Migrations\IVersionMigration.cs" />
+ <Compile Include="Migrations\LibraryScanMigration.cs" />
+ <Compile Include="Migrations\UpdateLevelMigration.cs" />
+ <Compile Include="News\NewsEntryPoint.cs" />
+ <Compile Include="News\NewsService.cs" />
+ <Compile Include="Notifications\CoreNotificationTypes.cs" />
+ <Compile Include="Notifications\IConfigurableNotificationService.cs" />
+ <Compile Include="Notifications\InternalNotificationService.cs" />
+ <Compile Include="Notifications\NotificationConfigurationFactory.cs" />
+ <Compile Include="Notifications\NotificationManager.cs" />
+ <Compile Include="Notifications\Notifications.cs" />
+ <Compile Include="Notifications\SqliteNotificationsRepository.cs" />
+ <Compile Include="Notifications\WebSocketNotifier.cs" />
+ <Compile Include="Data\BaseSqliteRepository.cs" />
+ <Compile Include="Data\CleanDatabaseScheduledTask.cs" />
+ <Compile Include="Data\SqliteExtensions.cs" />
+ <Compile Include="Photos\PhotoAlbumImageProvider.cs" />
+ <Compile Include="Playlists\PlaylistImageProvider.cs" />
+ <Compile Include="Playlists\PlaylistManager.cs" />
+ <Compile Include="Playlists\PlaylistsDynamicFolder.cs" />
+ <Compile Include="Properties\AssemblyInfo.cs" />
+ <Compile Include="ScheduledTasks\ChapterImagesTask.cs" />
+ <Compile Include="ScheduledTasks\PeopleValidationTask.cs" />
+ <Compile Include="ScheduledTasks\PluginUpdateTask.cs" />
+ <Compile Include="ScheduledTasks\RefreshIntrosTask.cs" />
+ <Compile Include="ScheduledTasks\RefreshMediaLibraryTask.cs" />
+ <Compile Include="ScheduledTasks\SystemUpdateTask.cs" />
+ <Compile Include="Security\AuthenticationRepository.cs" />
+ <Compile Include="Security\EncryptionManager.cs" />
+ <Compile Include="Security\MBLicenseFile.cs" />
+ <Compile Include="Security\PluginSecurityManager.cs" />
+ <Compile Include="Security\RegRecord.cs" />
+ <Compile Include="ServerManager\ServerManager.cs" />
+ <Compile Include="ServerManager\WebSocketConnection.cs" />
+ <Compile Include="Session\HttpSessionController.cs" />
+ <Compile Include="Session\SessionManager.cs" />
+ <Compile Include="Session\SessionWebSocketListener.cs" />
+ <Compile Include="Session\WebSocketController.cs" />
+ <Compile Include="Social\SharingManager.cs" />
+ <Compile Include="Social\SharingRepository.cs" />
+ <Compile Include="Sorting\AiredEpisodeOrderComparer.cs" />
+ <Compile Include="Sorting\AirTimeComparer.cs" />
+ <Compile Include="Sorting\AlbumArtistComparer.cs" />
+ <Compile Include="Sorting\AlbumComparer.cs" />
+ <Compile Include="Sorting\AlphanumComparator.cs" />
+ <Compile Include="Sorting\ArtistComparer.cs" />
+ <Compile Include="Sorting\BudgetComparer.cs" />
+ <Compile Include="Sorting\CommunityRatingComparer.cs" />
+ <Compile Include="Sorting\CriticRatingComparer.cs" />
+ <Compile Include="Sorting\DateCreatedComparer.cs" />
+ <Compile Include="Sorting\DateLastMediaAddedComparer.cs" />
+ <Compile Include="Sorting\DatePlayedComparer.cs" />
+ <Compile Include="Sorting\GameSystemComparer.cs" />
+ <Compile Include="Sorting\IsFavoriteOrLikeComparer.cs" />
+ <Compile Include="Sorting\IsFolderComparer.cs" />
+ <Compile Include="Sorting\IsPlayedComparer.cs" />
+ <Compile Include="Sorting\IsUnplayedComparer.cs" />
+ <Compile Include="Sorting\MetascoreComparer.cs" />
+ <Compile Include="Sorting\NameComparer.cs" />
+ <Compile Include="Sorting\OfficialRatingComparer.cs" />
+ <Compile Include="Sorting\PlayCountComparer.cs" />
+ <Compile Include="Sorting\PlayersComparer.cs" />
+ <Compile Include="Sorting\PremiereDateComparer.cs" />
+ <Compile Include="Sorting\ProductionYearComparer.cs" />
+ <Compile Include="Sorting\RandomComparer.cs" />
+ <Compile Include="Sorting\RevenueComparer.cs" />
+ <Compile Include="Sorting\RuntimeComparer.cs" />
+ <Compile Include="Sorting\SeriesSortNameComparer.cs" />
+ <Compile Include="Sorting\SortNameComparer.cs" />
+ <Compile Include="Sorting\StartDateComparer.cs" />
+ <Compile Include="Sorting\StudioComparer.cs" />
+ <Compile Include="StartupOptions.cs" />
+ <Compile Include="Sync\AppSyncProvider.cs" />
+ <Compile Include="Sync\CloudSyncProfile.cs" />
+ <Compile Include="Sync\IHasSyncQuality.cs" />
+ <Compile Include="Sync\MediaSync.cs" />
+ <Compile Include="Sync\MultiProviderSync.cs" />
+ <Compile Include="Sync\ServerSyncScheduledTask.cs" />
+ <Compile Include="Sync\SyncConfig.cs" />
+ <Compile Include="Sync\SyncConvertScheduledTask.cs" />
+ <Compile Include="Sync\SyncedMediaSourceProvider.cs" />
+ <Compile Include="Sync\SyncHelper.cs" />
+ <Compile Include="Sync\SyncJobOptions.cs" />
+ <Compile Include="Sync\SyncJobProcessor.cs" />
+ <Compile Include="Sync\SyncManager.cs" />
+ <Compile Include="Sync\SyncNotificationEntryPoint.cs" />
+ <Compile Include="Sync\SyncRegistrationInfo.cs" />
+ <Compile Include="Sync\SyncRepository.cs" />
+ <Compile Include="Sync\TargetDataProvider.cs" />
+ <Compile Include="TV\SeriesPostScanTask.cs" />
+ <Compile Include="TV\TVSeriesManager.cs" />
+ <Compile Include="Udp\UdpServer.cs" />
+ <Compile Include="Updates\InstallationManager.cs" />
+ <Compile Include="UserViews\CollectionFolderImageProvider.cs" />
+ <Compile Include="UserViews\DynamicImageProvider.cs" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\iso6392.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj">
+ <Project>{9142eefa-7570-41e1-bfcc-468bb571af2f}</Project>
+ <Name>MediaBrowser.Common</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj">
+ <Project>{17e1f4e6-8abd-4fe5-9ecf-43d4b6087ba2}</Project>
+ <Name>MediaBrowser.Controller</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj">
+ <Project>{7eeeb4bb-f3e8-48fc-b4c5-70f0fff8329b}</Project>
+ <Name>MediaBrowser.Model</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj">
+ <Project>{442b5058-dcaf-4263-bb6a-f21e31120a1b}</Project>
+ <Name>MediaBrowser.Providers</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\MediaBrowser.Server.Implementations\MediaBrowser.Server.Implementations.csproj">
+ <Project>{2e781478-814d-4a48-9d80-bff206441a65}</Project>
+ <Name>MediaBrowser.Server.Implementations</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\ServiceStack\ServiceStack.csproj">
+ <Project>{680a1709-25eb-4d52-a87f-ee03ffd94baa}</Project>
+ <Name>ServiceStack</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\SocketHttpListener.Portable\SocketHttpListener.Portable.csproj">
+ <Project>{4f26d5d8-a7b0-42b3-ba42-7cb7d245934e}</Project>
+ <Name>SocketHttpListener.Portable</Name>
+ </ProjectReference>
+ <Reference Include="Emby.XmlTv, Version=1.0.6193.39741, Culture=neutral, processorArchitecture=MSIL">
+ <HintPath>..\packages\Emby.XmlTv.1.0.3\lib\portable-net45+win8\Emby.XmlTv.dll</HintPath>
+ <Private>True</Private>
+ </Reference>
+ <Reference Include="MediaBrowser.Naming, Version=1.0.6178.4191, Culture=neutral, processorArchitecture=MSIL">
+ <HintPath>..\packages\MediaBrowser.Naming.1.0.3\lib\portable-net45+win8\MediaBrowser.Naming.dll</HintPath>
+ <Private>True</Private>
+ </Reference>
+ <Reference Include="SQLitePCL.pretty, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL">
+ <HintPath>..\packages\SQLitePCL.pretty.1.1.0\lib\portable-net45+netcore45+wpa81+wp8\SQLitePCL.pretty.dll</HintPath>
+ <Private>True</Private>
+ </Reference>
+ <Reference Include="SQLitePCLRaw.core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=1488e028ca7ab535, processorArchitecture=MSIL">
+ <HintPath>..\packages\SQLitePCLRaw.core.1.1.1\lib\portable-net45+netcore45+wpa81+MonoAndroid10+MonoTouch10+Xamarin.iOS10\SQLitePCLRaw.core.dll</HintPath>
+ <Private>True</Private>
+ </Reference>
+ <Reference Include="UniversalDetector, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL">
+ <HintPath>..\packages\UniversalDetector.1.0.1\lib\portable-net45+sl4+wp71+win8+wpa81\UniversalDetector.dll</HintPath>
+ <Private>True</Private>
+ </Reference>
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Core\ar.json" />
+ <EmbeddedResource Include="Localization\Core\bg-BG.json" />
+ <EmbeddedResource Include="Localization\Core\ca.json" />
+ <EmbeddedResource Include="Localization\Core\core.json" />
+ <EmbeddedResource Include="Localization\Core\cs.json" />
+ <EmbeddedResource Include="Localization\Core\da.json" />
+ <EmbeddedResource Include="Localization\Core\de.json" />
+ <EmbeddedResource Include="Localization\Core\el.json" />
+ <EmbeddedResource Include="Localization\Core\en-GB.json" />
+ <EmbeddedResource Include="Localization\Core\en-US.json" />
+ <EmbeddedResource Include="Localization\Core\es-AR.json" />
+ <EmbeddedResource Include="Localization\Core\es-MX.json" />
+ <EmbeddedResource Include="Localization\Core\es.json" />
+ <EmbeddedResource Include="Localization\Core\fi.json" />
+ <EmbeddedResource Include="Localization\Core\fr-CA.json" />
+ <EmbeddedResource Include="Localization\Core\fr.json" />
+ <EmbeddedResource Include="Localization\Core\gsw.json" />
+ <EmbeddedResource Include="Localization\Core\he.json" />
+ <EmbeddedResource Include="Localization\Core\hr.json" />
+ <EmbeddedResource Include="Localization\Core\hu.json" />
+ <EmbeddedResource Include="Localization\Core\id.json" />
+ <EmbeddedResource Include="Localization\Core\it.json" />
+ <EmbeddedResource Include="Localization\Core\kk.json" />
+ <EmbeddedResource Include="Localization\Core\ko.json" />
+ <EmbeddedResource Include="Localization\Core\ms.json" />
+ <EmbeddedResource Include="Localization\Core\nb.json" />
+ <EmbeddedResource Include="Localization\Core\nl.json" />
+ <EmbeddedResource Include="Localization\Core\pl.json" />
+ <EmbeddedResource Include="Localization\Core\pt-BR.json" />
+ <EmbeddedResource Include="Localization\Core\pt-PT.json" />
+ <EmbeddedResource Include="Localization\Core\ro.json" />
+ <EmbeddedResource Include="Localization\Core\ru.json" />
+ <EmbeddedResource Include="Localization\Core\sl-SI.json" />
+ <EmbeddedResource Include="Localization\Core\sv.json" />
+ <EmbeddedResource Include="Localization\Core\tr.json" />
+ <EmbeddedResource Include="Localization\Core\uk.json" />
+ <EmbeddedResource Include="Localization\Core\vi.json" />
+ <EmbeddedResource Include="Localization\Core\zh-CN.json" />
+ <EmbeddedResource Include="Localization\Core\zh-HK.json" />
+ <EmbeddedResource Include="Localization\Core\zh-TW.json" />
+ <EmbeddedResource Include="Localization\countries.json" />
+ <None Include="packages.config" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\au.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\be.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\br.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\ca.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\co.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\de.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\dk.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\fr.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\gb.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\ie.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\jp.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\kz.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\mx.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\nl.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\nz.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\ru.txt" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Localization\Ratings\us.txt" />
+ </ItemGroup>
+ <Import Project="$(MSBuildExtensionsPath32)\Microsoft\Portable\$(TargetFrameworkVersion)\Microsoft.Portable.CSharp.targets" />
+ <!-- To modify your build process, add your task inside one of the targets below and uncomment it.
+ Other similar extension points exist, see Microsoft.Common.targets.
+ <Target Name="BeforeBuild">
+ </Target>
+ <Target Name="AfterBuild">
+ </Target>
+ -->
+</Project> \ No newline at end of file
diff --git a/Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs
new file mode 100644
index 000000000..38708648f
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs
@@ -0,0 +1,124 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Tasks;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class AutomaticRestartEntryPoint : IServerEntryPoint
+ {
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+ private readonly ITaskManager _iTaskManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly ITimerFactory _timerFactory;
+
+ private ITimer _timer;
+
+ public AutomaticRestartEntryPoint(IServerApplicationHost appHost, ILogger logger, ITaskManager iTaskManager, ISessionManager sessionManager, IServerConfigurationManager config, ILiveTvManager liveTvManager, ITimerFactory timerFactory)
+ {
+ _appHost = appHost;
+ _logger = logger;
+ _iTaskManager = iTaskManager;
+ _sessionManager = sessionManager;
+ _config = config;
+ _liveTvManager = liveTvManager;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ if (_appHost.CanSelfRestart)
+ {
+ _appHost.HasPendingRestartChanged += _appHost_HasPendingRestartChanged;
+ }
+ }
+
+ void _appHost_HasPendingRestartChanged(object sender, EventArgs e)
+ {
+ DisposeTimer();
+
+ if (_appHost.HasPendingRestart)
+ {
+ _timer = _timerFactory.Create(TimerCallback, null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10));
+ }
+ }
+
+ private async void TimerCallback(object state)
+ {
+ if (_config.Configuration.EnableAutomaticRestart)
+ {
+ var isIdle = await IsIdle().ConfigureAwait(false);
+
+ if (isIdle)
+ {
+ DisposeTimer();
+
+ try
+ {
+ _appHost.Restart();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error restarting server", ex);
+ }
+ }
+ }
+ }
+
+ private async Task<bool> IsIdle()
+ {
+ if (_iTaskManager.ScheduledTasks.Any(i => i.State != TaskState.Idle))
+ {
+ return false;
+ }
+
+ if (_liveTvManager.Services.Count == 1)
+ {
+ try
+ {
+ var timers = await _liveTvManager.GetTimers(new TimerQuery(), CancellationToken.None).ConfigureAwait(false);
+ if (timers.Items.Any(i => i.Status == RecordingStatus.InProgress))
+ {
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting timers", ex);
+ }
+ }
+
+ var now = DateTime.UtcNow;
+
+ return !_sessionManager.Sessions.Any(i => (now - i.LastActivityDate).TotalMinutes < 30);
+ }
+
+ public void Dispose()
+ {
+ _appHost.HasPendingRestartChanged -= _appHost_HasPendingRestartChanged;
+
+ DisposeTimer();
+ }
+
+ private void DisposeTimer()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/KeepServerAwake.cs b/Emby.Server.Implementations/EntryPoints/KeepServerAwake.cs
new file mode 100644
index 000000000..8ae85e390
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/KeepServerAwake.cs
@@ -0,0 +1,65 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using MediaBrowser.Model.System;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class KeepServerAwake : IServerEntryPoint
+ {
+ private readonly ISessionManager _sessionManager;
+ private readonly ILogger _logger;
+ private ITimer _timer;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ITimerFactory _timerFactory;
+ private readonly IPowerManagement _powerManagement;
+
+ public KeepServerAwake(ISessionManager sessionManager, ILogger logger, IServerApplicationHost appHost, ITimerFactory timerFactory, IPowerManagement powerManagement)
+ {
+ _sessionManager = sessionManager;
+ _logger = logger;
+ _appHost = appHost;
+ _timerFactory = timerFactory;
+ _powerManagement = powerManagement;
+ }
+
+ public void Run()
+ {
+ _timer = _timerFactory.Create(OnTimerCallback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
+ }
+
+ private void OnTimerCallback(object state)
+ {
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ if (_sessionManager.Sessions.Any(i => (now - i.LastActivityDate).TotalMinutes < 15))
+ {
+ _powerManagement.PreventSystemStandby();
+ }
+ else
+ {
+ _powerManagement.AllowSystemStandby();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error resetting system standby timer", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
new file mode 100644
index 000000000..91142f928
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
@@ -0,0 +1,343 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class LibraryChangedNotifier : IServerEntryPoint
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ private readonly ISessionManager _sessionManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+ private readonly ITimerFactory _timerFactory;
+
+ /// <summary>
+ /// The _library changed sync lock
+ /// </summary>
+ private readonly object _libraryChangedSyncLock = new object();
+
+ private readonly List<Folder> _foldersAddedTo = new List<Folder>();
+ private readonly List<Folder> _foldersRemovedFrom = new List<Folder>();
+
+ private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
+ private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>();
+ private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>();
+
+ /// <summary>
+ /// Gets or sets the library update timer.
+ /// </summary>
+ /// <value>The library update timer.</value>
+ private ITimer LibraryUpdateTimer { get; set; }
+
+ /// <summary>
+ /// The library update duration
+ /// </summary>
+ private const int LibraryUpdateDuration = 5000;
+
+ public LibraryChangedNotifier(ILibraryManager libraryManager, ISessionManager sessionManager, IUserManager userManager, ILogger logger, ITimerFactory timerFactory)
+ {
+ _libraryManager = libraryManager;
+ _sessionManager = sessionManager;
+ _userManager = userManager;
+ _logger = logger;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ _libraryManager.ItemAdded += libraryManager_ItemAdded;
+ _libraryManager.ItemUpdated += libraryManager_ItemUpdated;
+ _libraryManager.ItemRemoved += libraryManager_ItemRemoved;
+
+ }
+
+ /// <summary>
+ /// Handles the ItemAdded event of the libraryManager control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
+ void libraryManager_ItemAdded(object sender, ItemChangeEventArgs e)
+ {
+ if (!FilterItem(e.Item))
+ {
+ return;
+ }
+
+ lock (_libraryChangedSyncLock)
+ {
+ if (LibraryUpdateTimer == null)
+ {
+ LibraryUpdateTimer = _timerFactory.Create(LibraryUpdateTimerCallback, null, LibraryUpdateDuration,
+ Timeout.Infinite);
+ }
+ else
+ {
+ LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
+ }
+
+ if (e.Item.Parent != null)
+ {
+ _foldersAddedTo.Add(e.Item.Parent);
+ }
+
+ _itemsAdded.Add(e.Item);
+ }
+ }
+
+ /// <summary>
+ /// Handles the ItemUpdated event of the libraryManager control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
+ void libraryManager_ItemUpdated(object sender, ItemChangeEventArgs e)
+ {
+ if (!FilterItem(e.Item))
+ {
+ return;
+ }
+
+ lock (_libraryChangedSyncLock)
+ {
+ if (LibraryUpdateTimer == null)
+ {
+ LibraryUpdateTimer = _timerFactory.Create(LibraryUpdateTimerCallback, null, LibraryUpdateDuration,
+ Timeout.Infinite);
+ }
+ else
+ {
+ LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
+ }
+
+ _itemsUpdated.Add(e.Item);
+ }
+ }
+
+ /// <summary>
+ /// Handles the ItemRemoved event of the libraryManager control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param>
+ void libraryManager_ItemRemoved(object sender, ItemChangeEventArgs e)
+ {
+ if (!FilterItem(e.Item))
+ {
+ return;
+ }
+
+ lock (_libraryChangedSyncLock)
+ {
+ if (LibraryUpdateTimer == null)
+ {
+ LibraryUpdateTimer = _timerFactory.Create(LibraryUpdateTimerCallback, null, LibraryUpdateDuration,
+ Timeout.Infinite);
+ }
+ else
+ {
+ LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
+ }
+
+ if (e.Item.Parent != null)
+ {
+ _foldersRemovedFrom.Add(e.Item.Parent);
+ }
+
+ _itemsRemoved.Add(e.Item);
+ }
+ }
+
+ /// <summary>
+ /// Libraries the update timer callback.
+ /// </summary>
+ /// <param name="state">The state.</param>
+ private void LibraryUpdateTimerCallback(object state)
+ {
+ lock (_libraryChangedSyncLock)
+ {
+ // Remove dupes in case some were saved multiple times
+ var foldersAddedTo = _foldersAddedTo.DistinctBy(i => i.Id).ToList();
+
+ var foldersRemovedFrom = _foldersRemovedFrom.DistinctBy(i => i.Id).ToList();
+
+ var itemsUpdated = _itemsUpdated
+ .Where(i => !_itemsAdded.Contains(i))
+ .DistinctBy(i => i.Id)
+ .ToList();
+
+ SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None);
+
+ if (LibraryUpdateTimer != null)
+ {
+ LibraryUpdateTimer.Dispose();
+ LibraryUpdateTimer = null;
+ }
+
+ _itemsAdded.Clear();
+ _itemsRemoved.Clear();
+ _itemsUpdated.Clear();
+ _foldersAddedTo.Clear();
+ _foldersRemovedFrom.Clear();
+ }
+ }
+
+ /// <summary>
+ /// Sends the change notifications.
+ /// </summary>
+ /// <param name="itemsAdded">The items added.</param>
+ /// <param name="itemsUpdated">The items updated.</param>
+ /// <param name="itemsRemoved">The items removed.</param>
+ /// <param name="foldersAddedTo">The folders added to.</param>
+ /// <param name="foldersRemovedFrom">The folders removed from.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ private async void SendChangeNotifications(List<BaseItem> itemsAdded, List<BaseItem> itemsUpdated, List<BaseItem> itemsRemoved, List<Folder> foldersAddedTo, List<Folder> foldersRemovedFrom, CancellationToken cancellationToken)
+ {
+ foreach (var user in _userManager.Users.ToList())
+ {
+ var id = user.Id;
+ var userSessions = _sessionManager.Sessions
+ .Where(u => u.UserId.HasValue && u.UserId.Value == id && u.SessionController != null && u.IsActive)
+ .ToList();
+
+ if (userSessions.Count > 0)
+ {
+ LibraryUpdateInfo info;
+
+ try
+ {
+ info = GetLibraryUpdateInfo(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo,
+ foldersRemovedFrom, id);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in GetLibraryUpdateInfo", ex);
+ return;
+ }
+
+ foreach (var userSession in userSessions)
+ {
+ try
+ {
+ await userSession.SessionController.SendLibraryUpdateInfo(info, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending LibraryChanged message", ex);
+ }
+ }
+ }
+
+ }
+ }
+
+ /// <summary>
+ /// Gets the library update info.
+ /// </summary>
+ /// <param name="itemsAdded">The items added.</param>
+ /// <param name="itemsUpdated">The items updated.</param>
+ /// <param name="itemsRemoved">The items removed.</param>
+ /// <param name="foldersAddedTo">The folders added to.</param>
+ /// <param name="foldersRemovedFrom">The folders removed from.</param>
+ /// <param name="userId">The user id.</param>
+ /// <returns>LibraryUpdateInfo.</returns>
+ private LibraryUpdateInfo GetLibraryUpdateInfo(IEnumerable<BaseItem> itemsAdded, IEnumerable<BaseItem> itemsUpdated, IEnumerable<BaseItem> itemsRemoved, IEnumerable<Folder> foldersAddedTo, IEnumerable<Folder> foldersRemovedFrom, Guid userId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ return new LibraryUpdateInfo
+ {
+ ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToList(),
+
+ ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToList(),
+
+ ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true)).Select(i => i.Id.ToString("N")).Distinct().ToList(),
+
+ FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToList(),
+
+ FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToList()
+ };
+ }
+
+ private bool FilterItem(BaseItem item)
+ {
+ if (!item.IsFolder && item.LocationType == LocationType.Virtual)
+ {
+ return false;
+ }
+
+ if (item is IItemByName && !(item is MusicArtist))
+ {
+ return false;
+ }
+
+ return item.SourceType == SourceType.Library;
+ }
+
+ /// <summary>
+ /// Translates the physical item to user library.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="item">The item.</param>
+ /// <param name="user">The user.</param>
+ /// <param name="includeIfNotFound">if set to <c>true</c> [include if not found].</param>
+ /// <returns>IEnumerable{``0}.</returns>
+ private IEnumerable<T> TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false)
+ where T : BaseItem
+ {
+ // If the physical root changed, return the user root
+ if (item is AggregateFolder)
+ {
+ return new[] { user.RootFolder as T };
+ }
+
+ // Return it only if it's in the user's library
+ if (includeIfNotFound || item.IsVisibleStandalone(user))
+ {
+ return new[] { item };
+ }
+
+ return new T[] { };
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ if (LibraryUpdateTimer != null)
+ {
+ LibraryUpdateTimer.Dispose();
+ LibraryUpdateTimer = null;
+ }
+
+ _libraryManager.ItemAdded -= libraryManager_ItemAdded;
+ _libraryManager.ItemUpdated -= libraryManager_ItemUpdated;
+ _libraryManager.ItemRemoved -= libraryManager_ItemRemoved;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/LoadRegistrations.cs b/Emby.Server.Implementations/EntryPoints/LoadRegistrations.cs
new file mode 100644
index 000000000..0203b5192
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/LoadRegistrations.cs
@@ -0,0 +1,73 @@
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class LoadRegistrations
+ /// </summary>
+ public class LoadRegistrations : IServerEntryPoint
+ {
+ /// <summary>
+ /// The _security manager
+ /// </summary>
+ private readonly ISecurityManager _securityManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private ITimer _timer;
+ private readonly ITimerFactory _timerFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LoadRegistrations" /> class.
+ /// </summary>
+ /// <param name="securityManager">The security manager.</param>
+ /// <param name="logManager">The log manager.</param>
+ public LoadRegistrations(ISecurityManager securityManager, ILogManager logManager, ITimerFactory timerFactory)
+ {
+ _securityManager = securityManager;
+ _timerFactory = timerFactory;
+
+ _logger = logManager.GetLogger("Registration Loader");
+ }
+
+ /// <summary>
+ /// Runs this instance.
+ /// </summary>
+ public void Run()
+ {
+ _timer = _timerFactory.Create(s => LoadAllRegistrations(), null, TimeSpan.FromMilliseconds(100), TimeSpan.FromHours(12));
+ }
+
+ private async Task LoadAllRegistrations()
+ {
+ try
+ {
+ await _securityManager.LoadAllRegistrationInfo().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error loading registration info", ex);
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs
new file mode 100644
index 000000000..b674fc39b
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class RecordingNotifier : IServerEntryPoint
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+
+ public RecordingNotifier(ISessionManager sessionManager, IUserManager userManager, ILogger logger, ILiveTvManager liveTvManager)
+ {
+ _sessionManager = sessionManager;
+ _userManager = userManager;
+ _logger = logger;
+ _liveTvManager = liveTvManager;
+ }
+
+ public void Run()
+ {
+ _liveTvManager.TimerCancelled += _liveTvManager_TimerCancelled;
+ _liveTvManager.SeriesTimerCancelled += _liveTvManager_SeriesTimerCancelled;
+ _liveTvManager.TimerCreated += _liveTvManager_TimerCreated;
+ _liveTvManager.SeriesTimerCreated += _liveTvManager_SeriesTimerCreated;
+ }
+
+ private void _liveTvManager_SeriesTimerCreated(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e)
+ {
+ SendMessage("SeriesTimerCreated", e.Argument);
+ }
+
+ private void _liveTvManager_TimerCreated(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e)
+ {
+ SendMessage("TimerCreated", e.Argument);
+ }
+
+ private void _liveTvManager_SeriesTimerCancelled(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e)
+ {
+ SendMessage("SeriesTimerCancelled", e.Argument);
+ }
+
+ private void _liveTvManager_TimerCancelled(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e)
+ {
+ SendMessage("TimerCancelled", e.Argument);
+ }
+
+ private async void SendMessage(string name, TimerEventInfo info)
+ {
+ var users = _userManager.Users.Where(i => i.Policy.EnableLiveTvAccess).Select(i => i.Id.ToString("N")).ToList();
+
+ try
+ {
+ await _sessionManager.SendMessageToUserSessions<TimerEventInfo>(users, name, info, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending message", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ _liveTvManager.TimerCancelled -= _liveTvManager_TimerCancelled;
+ _liveTvManager.SeriesTimerCancelled -= _liveTvManager_SeriesTimerCancelled;
+ _liveTvManager.TimerCreated -= _liveTvManager_TimerCreated;
+ _liveTvManager.SeriesTimerCreated -= _liveTvManager_SeriesTimerCreated;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs b/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs
new file mode 100644
index 000000000..77de849a1
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs
@@ -0,0 +1,41 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using System.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class RefreshUsersMetadata
+ /// </summary>
+ public class RefreshUsersMetadata : IServerEntryPoint
+ {
+ /// <summary>
+ /// The _user manager
+ /// </summary>
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshUsersMetadata" /> class.
+ /// </summary>
+ /// <param name="userManager">The user manager.</param>
+ public RefreshUsersMetadata(IUserManager userManager)
+ {
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Runs this instance.
+ /// </summary>
+ public async void Run()
+ {
+ await _userManager.RefreshUsersMetadata(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs b/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs
new file mode 100644
index 000000000..4d640bc95
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs
@@ -0,0 +1,203 @@
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class WebSocketEvents
+ /// </summary>
+ public class ServerEventNotifier : IServerEntryPoint
+ {
+ /// <summary>
+ /// The _server manager
+ /// </summary>
+ private readonly IServerManager _serverManager;
+
+ /// <summary>
+ /// The _user manager
+ /// </summary>
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// The _installation manager
+ /// </summary>
+ private readonly IInstallationManager _installationManager;
+
+ /// <summary>
+ /// The _kernel
+ /// </summary>
+ private readonly IServerApplicationHost _appHost;
+
+ /// <summary>
+ /// The _task manager
+ /// </summary>
+ private readonly ITaskManager _taskManager;
+
+ private readonly ISessionManager _sessionManager;
+ private readonly ISyncManager _syncManager;
+
+ public ServerEventNotifier(IServerManager serverManager, IServerApplicationHost appHost, IUserManager userManager, IInstallationManager installationManager, ITaskManager taskManager, ISessionManager sessionManager, ISyncManager syncManager)
+ {
+ _serverManager = serverManager;
+ _userManager = userManager;
+ _installationManager = installationManager;
+ _appHost = appHost;
+ _taskManager = taskManager;
+ _sessionManager = sessionManager;
+ _syncManager = syncManager;
+ }
+
+ public void Run()
+ {
+ _userManager.UserDeleted += userManager_UserDeleted;
+ _userManager.UserUpdated += userManager_UserUpdated;
+ _userManager.UserConfigurationUpdated += _userManager_UserConfigurationUpdated;
+
+ _appHost.HasPendingRestartChanged += kernel_HasPendingRestartChanged;
+
+ _installationManager.PluginUninstalled += InstallationManager_PluginUninstalled;
+ _installationManager.PackageInstalling += _installationManager_PackageInstalling;
+ _installationManager.PackageInstallationCancelled += _installationManager_PackageInstallationCancelled;
+ _installationManager.PackageInstallationCompleted += _installationManager_PackageInstallationCompleted;
+ _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed;
+
+ _taskManager.TaskCompleted += _taskManager_TaskCompleted;
+ _syncManager.SyncJobCreated += _syncManager_SyncJobCreated;
+ _syncManager.SyncJobCancelled += _syncManager_SyncJobCancelled;
+ }
+
+ void _syncManager_SyncJobCancelled(object sender, GenericEventArgs<SyncJob> e)
+ {
+ _sessionManager.SendMessageToUserDeviceSessions(e.Argument.TargetId, "SyncJobCancelled", e.Argument, CancellationToken.None);
+ }
+
+ void _syncManager_SyncJobCreated(object sender, GenericEventArgs<SyncJobCreationResult> e)
+ {
+ _sessionManager.SendMessageToUserDeviceSessions(e.Argument.Job.TargetId, "SyncJobCreated", e.Argument, CancellationToken.None);
+ }
+
+ void _installationManager_PackageInstalling(object sender, InstallationEventArgs e)
+ {
+ _serverManager.SendWebSocketMessage("PackageInstalling", e.InstallationInfo);
+ }
+
+ void _installationManager_PackageInstallationCancelled(object sender, InstallationEventArgs e)
+ {
+ _serverManager.SendWebSocketMessage("PackageInstallationCancelled", e.InstallationInfo);
+ }
+
+ void _installationManager_PackageInstallationCompleted(object sender, InstallationEventArgs e)
+ {
+ _serverManager.SendWebSocketMessage("PackageInstallationCompleted", e.InstallationInfo);
+ }
+
+ void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e)
+ {
+ _serverManager.SendWebSocketMessage("PackageInstallationFailed", e.InstallationInfo);
+ }
+
+ void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e)
+ {
+ _serverManager.SendWebSocketMessage("ScheduledTaskEnded", e.Result);
+ }
+
+ /// <summary>
+ /// Installations the manager_ plugin uninstalled.
+ /// </summary>
+ /// <param name="sender">The sender.</param>
+ /// <param name="e">The e.</param>
+ void InstallationManager_PluginUninstalled(object sender, GenericEventArgs<IPlugin> e)
+ {
+ _serverManager.SendWebSocketMessage("PluginUninstalled", e.Argument.GetPluginInfo());
+ }
+
+ /// <summary>
+ /// Handles the HasPendingRestartChanged event of the kernel control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
+ void kernel_HasPendingRestartChanged(object sender, EventArgs e)
+ {
+ _sessionManager.SendRestartRequiredNotification(CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Users the manager_ user updated.
+ /// </summary>
+ /// <param name="sender">The sender.</param>
+ /// <param name="e">The e.</param>
+ void userManager_UserUpdated(object sender, GenericEventArgs<User> e)
+ {
+ var dto = _userManager.GetUserDto(e.Argument);
+
+ SendMessageToUserSession(e.Argument, "UserUpdated", dto);
+ }
+
+ /// <summary>
+ /// Users the manager_ user deleted.
+ /// </summary>
+ /// <param name="sender">The sender.</param>
+ /// <param name="e">The e.</param>
+ void userManager_UserDeleted(object sender, GenericEventArgs<User> e)
+ {
+ SendMessageToUserSession(e.Argument, "UserDeleted", e.Argument.Id.ToString("N"));
+ }
+
+ void _userManager_UserConfigurationUpdated(object sender, GenericEventArgs<User> e)
+ {
+ var dto = _userManager.GetUserDto(e.Argument);
+
+ SendMessageToUserSession(e.Argument, "UserConfigurationUpdated", dto);
+ }
+
+ private async void SendMessageToUserSession<T>(User user, string name, T data)
+ {
+ await _sessionManager.SendMessageToUserSessions(new List<string> { user.Id.ToString("N") }, name, data, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ _userManager.UserDeleted -= userManager_UserDeleted;
+ _userManager.UserUpdated -= userManager_UserUpdated;
+ _userManager.UserConfigurationUpdated -= _userManager_UserConfigurationUpdated;
+
+ _installationManager.PluginUninstalled -= InstallationManager_PluginUninstalled;
+ _installationManager.PackageInstalling -= _installationManager_PackageInstalling;
+ _installationManager.PackageInstallationCancelled -= _installationManager_PackageInstallationCancelled;
+ _installationManager.PackageInstallationCompleted -= _installationManager_PackageInstallationCompleted;
+ _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed;
+
+ _appHost.HasPendingRestartChanged -= kernel_HasPendingRestartChanged;
+ _syncManager.SyncJobCreated -= _syncManager_SyncJobCreated;
+ _syncManager.SyncJobCancelled -= _syncManager_SyncJobCancelled;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/StartupWizard.cs b/Emby.Server.Implementations/EntryPoints/StartupWizard.cs
new file mode 100644
index 000000000..424153f22
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/StartupWizard.cs
@@ -0,0 +1,59 @@
+using Emby.Server.Implementations.Browser;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class StartupWizard
+ /// </summary>
+ public class StartupWizard : IServerEntryPoint
+ {
+ /// <summary>
+ /// The _app host
+ /// </summary>
+ private readonly IServerApplicationHost _appHost;
+ /// <summary>
+ /// The _user manager
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupWizard" /> class.
+ /// </summary>
+ /// <param name="appHost">The app host.</param>
+ /// <param name="logger">The logger.</param>
+ public StartupWizard(IServerApplicationHost appHost, ILogger logger)
+ {
+ _appHost = appHost;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Runs this instance.
+ /// </summary>
+ public void Run()
+ {
+ if (_appHost.IsFirstRun)
+ {
+ LaunchStartupWizard();
+ }
+ }
+
+ /// <summary>
+ /// Launches the startup wizard.
+ /// </summary>
+ private void LaunchStartupWizard()
+ {
+ BrowserLauncher.OpenDashboardPage("wizardstart.html", _appHost);
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/EntryPoints/SystemEvents.cs b/Emby.Server.Implementations/EntryPoints/SystemEvents.cs
new file mode 100644
index 000000000..021ae47ec
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/SystemEvents.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.System;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Common;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class SystemEvents : IServerEntryPoint
+ {
+ private readonly ISystemEvents _systemEvents;
+ private readonly IApplicationHost _appHost;
+
+ public SystemEvents(ISystemEvents systemEvents, IApplicationHost appHost)
+ {
+ _systemEvents = systemEvents;
+ _appHost = appHost;
+ }
+
+ public void Run()
+ {
+ _systemEvents.SessionLogoff += _systemEvents_SessionLogoff;
+ _systemEvents.SystemShutdown += _systemEvents_SystemShutdown;
+ }
+
+ private void _systemEvents_SessionLogoff(object sender, EventArgs e)
+ {
+ if (!_appHost.IsRunningAsService)
+ {
+ _appHost.Shutdown();
+ }
+ }
+
+ private void _systemEvents_SystemShutdown(object sender, EventArgs e)
+ {
+ _appHost.Shutdown();
+ }
+
+ public void Dispose()
+ {
+ _systemEvents.SystemShutdown -= _systemEvents_SystemShutdown;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
new file mode 100644
index 000000000..df5a7c985
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
@@ -0,0 +1,85 @@
+using System;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using Emby.Server.Implementations.Udp;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class UdpServerEntryPoint
+ /// </summary>
+ public class UdpServerEntryPoint : IServerEntryPoint
+ {
+ /// <summary>
+ /// Gets or sets the UDP server.
+ /// </summary>
+ /// <value>The UDP server.</value>
+ private UdpServer UdpServer { get; set; }
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly ISocketFactory _socketFactory;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IJsonSerializer _json;
+
+ public const int PortNumber = 7359;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class.
+ /// </summary>
+ public UdpServerEntryPoint(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory)
+ {
+ _logger = logger;
+ _appHost = appHost;
+ _json = json;
+ _socketFactory = socketFactory;
+ }
+
+ /// <summary>
+ /// Runs this instance.
+ /// </summary>
+ public void Run()
+ {
+ var udpServer = new UdpServer(_logger, _appHost, _json, _socketFactory);
+
+ try
+ {
+ udpServer.Start(PortNumber);
+
+ UdpServer = udpServer;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Failed to start UDP Server", ex);
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ if (UdpServer != null)
+ {
+ UdpServer.Dispose();
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/UsageEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UsageEntryPoint.cs
new file mode 100644
index 000000000..1b897ca29
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/UsageEntryPoint.cs
@@ -0,0 +1,133 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ /// <summary>
+ /// Class UsageEntryPoint
+ /// </summary>
+ public class UsageEntryPoint : IServerEntryPoint
+ {
+ private readonly IApplicationHost _applicationHost;
+ private readonly IHttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly ISessionManager _sessionManager;
+ private readonly IUserManager _userManager;
+ private readonly IServerConfigurationManager _config;
+
+ private readonly ConcurrentDictionary<Guid, ClientInfo> _apps = new ConcurrentDictionary<Guid, ClientInfo>();
+
+ public UsageEntryPoint(ILogger logger, IApplicationHost applicationHost, IHttpClient httpClient, ISessionManager sessionManager, IUserManager userManager, IServerConfigurationManager config)
+ {
+ _logger = logger;
+ _applicationHost = applicationHost;
+ _httpClient = httpClient;
+ _sessionManager = sessionManager;
+ _userManager = userManager;
+ _config = config;
+
+ _sessionManager.SessionStarted += _sessionManager_SessionStarted;
+ }
+
+ void _sessionManager_SessionStarted(object sender, SessionEventArgs e)
+ {
+ var session = e.SessionInfo;
+
+ if (!string.IsNullOrEmpty(session.Client) &&
+ !string.IsNullOrEmpty(session.DeviceName) &&
+ !string.IsNullOrEmpty(session.DeviceId) &&
+ !string.IsNullOrEmpty(session.ApplicationVersion))
+ {
+ var keys = new List<string>
+ {
+ session.Client,
+ session.DeviceName,
+ session.DeviceId,
+ session.ApplicationVersion
+ };
+
+ var key = string.Join("_", keys.ToArray()).GetMD5();
+
+ _apps.GetOrAdd(key, guid => GetNewClientInfo(session));
+ }
+ }
+
+ private async void ReportNewSession(ClientInfo client)
+ {
+ if (!_config.Configuration.EnableAnonymousUsageReporting)
+ {
+ return;
+ }
+
+ try
+ {
+ await new UsageReporter(_applicationHost, _httpClient, _userManager, _logger)
+ .ReportAppUsage(client, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending anonymous usage statistics.", ex);
+ }
+ }
+
+ private ClientInfo GetNewClientInfo(SessionInfo session)
+ {
+ var info = new ClientInfo
+ {
+ AppName = session.Client,
+ AppVersion = session.ApplicationVersion,
+ DeviceName = session.DeviceName,
+ DeviceId = session.DeviceId
+ };
+
+ ReportNewSession(info);
+
+ return info;
+ }
+
+ public async void Run()
+ {
+ await Task.Delay(5000).ConfigureAwait(false);
+ OnTimerFired();
+ }
+
+ /// <summary>
+ /// Called when [timer fired].
+ /// </summary>
+ private async void OnTimerFired()
+ {
+ if (!_config.Configuration.EnableAnonymousUsageReporting)
+ {
+ return;
+ }
+
+ try
+ {
+ await new UsageReporter(_applicationHost, _httpClient, _userManager, _logger)
+ .ReportServerUsage(CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending anonymous usage statistics.", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ _sessionManager.SessionStarted -= _sessionManager_SessionStarted;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/UsageReporter.cs b/Emby.Server.Implementations/EntryPoints/UsageReporter.cs
new file mode 100644
index 000000000..be848acb7
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/UsageReporter.cs
@@ -0,0 +1,138 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Connect;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ public class UsageReporter
+ {
+ private readonly IApplicationHost _applicationHost;
+ private readonly IHttpClient _httpClient;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+ private const string MbAdminUrl = "https://www.mb3admin.com/admin/";
+
+ public UsageReporter(IApplicationHost applicationHost, IHttpClient httpClient, IUserManager userManager, ILogger logger)
+ {
+ _applicationHost = applicationHost;
+ _httpClient = httpClient;
+ _userManager = userManager;
+ _logger = logger;
+ }
+
+ public async Task ReportServerUsage(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var data = new Dictionary<string, string>
+ {
+ { "feature", _applicationHost.Name },
+ { "mac", _applicationHost.SystemId },
+ { "serverid", _applicationHost.SystemId },
+ { "deviceid", _applicationHost.SystemId },
+ { "ver", _applicationHost.ApplicationVersion.ToString() },
+ { "platform", _applicationHost.OperatingSystemDisplayName },
+ { "isservice", _applicationHost.IsRunningAsService.ToString().ToLower()}
+ };
+
+ var users = _userManager.Users.ToList();
+
+ data["localusers"] = users.Count(i => !i.ConnectLinkType.HasValue).ToString(CultureInfo.InvariantCulture);
+ data["guests"] = users.Count(i => i.ConnectLinkType.HasValue && i.ConnectLinkType.Value == UserLinkType.Guest).ToString(CultureInfo.InvariantCulture);
+ data["linkedusers"] = users.Count(i => i.ConnectLinkType.HasValue && i.ConnectLinkType.Value == UserLinkType.LinkedUser).ToString(CultureInfo.InvariantCulture);
+
+ data["plugins"] = string.Join(",", _applicationHost.Plugins.Select(i => i.Id).ToArray());
+
+ var logErrors = false;
+#if DEBUG
+ logErrors = true;
+#endif
+ var options = new HttpRequestOptions
+ {
+ Url = MbAdminUrl + "service/registration/ping",
+ CancellationToken = cancellationToken,
+
+ // Seeing block length errors
+ EnableHttpCompression = false,
+
+ LogRequest = false,
+ LogErrors = logErrors,
+ BufferContent = false
+ };
+
+ options.SetPostData(data);
+
+ using (var response = await _httpClient.SendAsync(options, "POST").ConfigureAwait(false))
+ {
+
+ }
+ }
+
+ public async Task ReportAppUsage(ClientInfo app, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(app.DeviceId))
+ {
+ throw new ArgumentException("Client info must have a device Id");
+ }
+
+ _logger.Info("App Activity: app: {0}, version: {1}, deviceId: {2}, deviceName: {3}",
+ app.AppName ?? "Unknown App",
+ app.AppVersion ?? "Unknown",
+ app.DeviceId,
+ app.DeviceName ?? "Unknown");
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var data = new Dictionary<string, string>
+ {
+ { "feature", app.AppName ?? "Unknown App" },
+ { "serverid", _applicationHost.SystemId },
+ { "deviceid", app.DeviceId },
+ { "mac", app.DeviceId },
+ { "ver", app.AppVersion ?? "Unknown" },
+ { "platform", app.DeviceName },
+ };
+
+ var logErrors = false;
+
+#if DEBUG
+ logErrors = true;
+#endif
+ var options = new HttpRequestOptions
+ {
+ Url = MbAdminUrl + "service/registration/ping",
+ CancellationToken = cancellationToken,
+
+ // Seeing block length errors
+ EnableHttpCompression = false,
+
+ LogRequest = false,
+ LogErrors = logErrors,
+ BufferContent = false
+ };
+
+ options.SetPostData(data);
+
+ using (var response = await _httpClient.SendAsync(options, "POST").ConfigureAwait(false))
+ {
+
+ }
+ }
+ }
+
+ public class ClientInfo
+ {
+ public string AppName { get; set; }
+ public string AppVersion { get; set; }
+ public string DeviceName { get; set; }
+ public string DeviceId { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
new file mode 100644
index 000000000..b93410180
--- /dev/null
+++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
@@ -0,0 +1,165 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Session;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.EntryPoints
+{
+ class UserDataChangeNotifier : IServerEntryPoint
+ {
+ private readonly ISessionManager _sessionManager;
+ private readonly ILogger _logger;
+ private readonly IUserDataManager _userDataManager;
+ private readonly IUserManager _userManager;
+
+ private readonly object _syncLock = new object();
+ private ITimer UpdateTimer { get; set; }
+ private readonly ITimerFactory _timerFactory;
+ private const int UpdateDuration = 500;
+
+ private readonly Dictionary<Guid, List<IHasUserData>> _changedItems = new Dictionary<Guid, List<IHasUserData>>();
+
+ public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, ILogger logger, IUserManager userManager, ITimerFactory timerFactory)
+ {
+ _userDataManager = userDataManager;
+ _sessionManager = sessionManager;
+ _logger = logger;
+ _userManager = userManager;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ _userDataManager.UserDataSaved += _userDataManager_UserDataSaved;
+ }
+
+ void _userDataManager_UserDataSaved(object sender, UserDataSaveEventArgs e)
+ {
+ if (e.SaveReason == UserDataSaveReason.PlaybackProgress)
+ {
+ return;
+ }
+
+ lock (_syncLock)
+ {
+ if (UpdateTimer == null)
+ {
+ UpdateTimer = _timerFactory.Create(UpdateTimerCallback, null, UpdateDuration,
+ Timeout.Infinite);
+ }
+ else
+ {
+ UpdateTimer.Change(UpdateDuration, Timeout.Infinite);
+ }
+
+ List<IHasUserData> keys;
+
+ if (!_changedItems.TryGetValue(e.UserId, out keys))
+ {
+ keys = new List<IHasUserData>();
+ _changedItems[e.UserId] = keys;
+ }
+
+ keys.Add(e.Item);
+
+ var baseItem = e.Item as BaseItem;
+
+ // Go up one level for indicators
+ if (baseItem != null)
+ {
+ var parent = baseItem.GetParent();
+
+ if (parent != null)
+ {
+ keys.Add(parent);
+ }
+ }
+ }
+ }
+
+ private void UpdateTimerCallback(object state)
+ {
+ lock (_syncLock)
+ {
+ // Remove dupes in case some were saved multiple times
+ var changes = _changedItems.ToList();
+ _changedItems.Clear();
+
+ var task = SendNotifications(changes, CancellationToken.None);
+
+ if (UpdateTimer != null)
+ {
+ UpdateTimer.Dispose();
+ UpdateTimer = null;
+ }
+ }
+ }
+
+ private async Task SendNotifications(IEnumerable<KeyValuePair<Guid, List<IHasUserData>>> changes, CancellationToken cancellationToken)
+ {
+ foreach (var pair in changes)
+ {
+ var userId = pair.Key;
+ var userSessions = _sessionManager.Sessions
+ .Where(u => u.ContainsUser(userId) && u.SessionController != null && u.IsActive)
+ .ToList();
+
+ if (userSessions.Count > 0)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var dtoList = pair.Value
+ .DistinctBy(i => i.Id)
+ .Select(i =>
+ {
+ var dto = _userDataManager.GetUserDataDto(i, user).Result;
+ dto.ItemId = i.Id.ToString("N");
+ return dto;
+ })
+ .ToList();
+
+ var info = new UserDataChangeInfo
+ {
+ UserId = userId.ToString("N"),
+
+ UserDataList = dtoList
+ };
+
+ foreach (var userSession in userSessions)
+ {
+ try
+ {
+ await userSession.SessionController.SendUserDataChangeInfo(info, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending UserDataChanged message", ex);
+ }
+ }
+ }
+
+ }
+ }
+
+ public void Dispose()
+ {
+ if (UpdateTimer != null)
+ {
+ UpdateTimer.Dispose();
+ UpdateTimer = null;
+ }
+
+ _userDataManager.UserDataSaved -= _userDataManager_UserDataSaved;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs b/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs
new file mode 100644
index 000000000..e725d22f5
--- /dev/null
+++ b/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs
@@ -0,0 +1,24 @@
+namespace Emby.Server.Implementations.FFMpeg
+{
+ /// <summary>
+ /// Class FFMpegInfo
+ /// </summary>
+ public class FFMpegInfo
+ {
+ /// <summary>
+ /// Gets or sets the path.
+ /// </summary>
+ /// <value>The path.</value>
+ public string EncoderPath { get; set; }
+ /// <summary>
+ /// Gets or sets the probe path.
+ /// </summary>
+ /// <value>The probe path.</value>
+ public string ProbePath { get; set; }
+ /// <summary>
+ /// Gets or sets the version.
+ /// </summary>
+ /// <value>The version.</value>
+ public string Version { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs b/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs
new file mode 100644
index 000000000..1d769acec
--- /dev/null
+++ b/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs
@@ -0,0 +1,20 @@
+
+namespace Emby.Server.Implementations.FFMpeg
+{
+ public class FFMpegInstallInfo
+ {
+ public string Version { get; set; }
+ public string FFMpegFilename { get; set; }
+ public string FFProbeFilename { get; set; }
+ public string ArchiveType { get; set; }
+ public string[] DownloadUrls { get; set; }
+
+ public FFMpegInstallInfo()
+ {
+ DownloadUrls = new string[] { };
+ Version = "Path";
+ FFMpegFilename = "ffmpeg";
+ FFProbeFilename = "ffprobe";
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs b/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs
new file mode 100644
index 000000000..2becebb3d
--- /dev/null
+++ b/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs
@@ -0,0 +1,240 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations;
+using Emby.Server.Implementations.FFMpeg;
+
+namespace Emby.Server.Implementations.FFMpeg
+{
+ public class FFMpegLoader
+ {
+ private readonly IHttpClient _httpClient;
+ private readonly IApplicationPaths _appPaths;
+ private readonly ILogger _logger;
+ private readonly IZipClient _zipClient;
+ private readonly IFileSystem _fileSystem;
+ private readonly FFMpegInstallInfo _ffmpegInstallInfo;
+
+ public FFMpegLoader(ILogger logger, IApplicationPaths appPaths, IHttpClient httpClient, IZipClient zipClient, IFileSystem fileSystem, FFMpegInstallInfo ffmpegInstallInfo)
+ {
+ _logger = logger;
+ _appPaths = appPaths;
+ _httpClient = httpClient;
+ _zipClient = zipClient;
+ _fileSystem = fileSystem;
+ _ffmpegInstallInfo = ffmpegInstallInfo;
+ }
+
+ public async Task<FFMpegInfo> GetFFMpegInfo(StartupOptions options, IProgress<double> progress)
+ {
+ var customffMpegPath = options.GetOption("-ffmpeg");
+ var customffProbePath = options.GetOption("-ffprobe");
+
+ if (!string.IsNullOrWhiteSpace(customffMpegPath) && !string.IsNullOrWhiteSpace(customffProbePath))
+ {
+ return new FFMpegInfo
+ {
+ ProbePath = customffProbePath,
+ EncoderPath = customffMpegPath,
+ Version = "external"
+ };
+ }
+
+ var downloadInfo = _ffmpegInstallInfo;
+
+ var version = downloadInfo.Version;
+
+ if (string.Equals(version, "path", StringComparison.OrdinalIgnoreCase))
+ {
+ return new FFMpegInfo
+ {
+ ProbePath = downloadInfo.FFProbeFilename,
+ EncoderPath = downloadInfo.FFMpegFilename,
+ Version = version
+ };
+ }
+
+ if (string.Equals(version, "0", StringComparison.OrdinalIgnoreCase))
+ {
+ return new FFMpegInfo();
+ }
+
+ var rootEncoderPath = Path.Combine(_appPaths.ProgramDataPath, "ffmpeg");
+ var versionedDirectoryPath = Path.Combine(rootEncoderPath, version);
+
+ var info = new FFMpegInfo
+ {
+ ProbePath = Path.Combine(versionedDirectoryPath, downloadInfo.FFProbeFilename),
+ EncoderPath = Path.Combine(versionedDirectoryPath, downloadInfo.FFMpegFilename),
+ Version = version
+ };
+
+ _fileSystem.CreateDirectory(versionedDirectoryPath);
+
+ var excludeFromDeletions = new List<string> { versionedDirectoryPath };
+
+ if (!_fileSystem.FileExists(info.ProbePath) || !_fileSystem.FileExists(info.EncoderPath))
+ {
+ // ffmpeg not present. See if there's an older version we can start with
+ var existingVersion = GetExistingVersion(info, rootEncoderPath);
+
+ // No older version. Need to download and block until complete
+ if (existingVersion == null)
+ {
+ var success = await DownloadFFMpeg(downloadInfo, versionedDirectoryPath, progress).ConfigureAwait(false);
+ if (!success)
+ {
+ return new FFMpegInfo();
+ }
+ }
+ else
+ {
+ info = existingVersion;
+ versionedDirectoryPath = Path.GetDirectoryName(info.EncoderPath);
+ excludeFromDeletions.Add(versionedDirectoryPath);
+ }
+ }
+
+ // Allow just one of these to be overridden, if desired.
+ if (!string.IsNullOrWhiteSpace(customffMpegPath))
+ {
+ info.EncoderPath = customffMpegPath;
+ }
+ if (!string.IsNullOrWhiteSpace(customffProbePath))
+ {
+ info.EncoderPath = customffProbePath;
+ }
+
+ return info;
+ }
+
+ private FFMpegInfo GetExistingVersion(FFMpegInfo info, string rootEncoderPath)
+ {
+ var encoderFilename = Path.GetFileName(info.EncoderPath);
+ var probeFilename = Path.GetFileName(info.ProbePath);
+
+ foreach (var directory in _fileSystem.GetDirectoryPaths(rootEncoderPath)
+ .ToList())
+ {
+ var allFiles = _fileSystem.GetFilePaths(directory, true).ToList();
+
+ var encoder = allFiles.FirstOrDefault(i => string.Equals(Path.GetFileName(i), encoderFilename, StringComparison.OrdinalIgnoreCase));
+ var probe = allFiles.FirstOrDefault(i => string.Equals(Path.GetFileName(i), probeFilename, StringComparison.OrdinalIgnoreCase));
+
+ if (!string.IsNullOrWhiteSpace(encoder) &&
+ !string.IsNullOrWhiteSpace(probe))
+ {
+ return new FFMpegInfo
+ {
+ EncoderPath = encoder,
+ ProbePath = probe,
+ Version = Path.GetFileName(Path.GetDirectoryName(probe))
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private async Task<bool> DownloadFFMpeg(FFMpegInstallInfo downloadinfo, string directory, IProgress<double> progress)
+ {
+ foreach (var url in downloadinfo.DownloadUrls)
+ {
+ progress.Report(0);
+
+ try
+ {
+ var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = CancellationToken.None,
+ Progress = progress
+
+ }).ConfigureAwait(false);
+
+ ExtractFFMpeg(downloadinfo, tempFile, directory);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error downloading {0}", ex, url);
+ }
+ }
+ return false;
+ }
+
+ private void ExtractFFMpeg(FFMpegInstallInfo downloadinfo, string tempFile, string targetFolder)
+ {
+ _logger.Info("Extracting ffmpeg from {0}", tempFile);
+
+ var tempFolder = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString());
+
+ _fileSystem.CreateDirectory(tempFolder);
+
+ try
+ {
+ ExtractArchive(downloadinfo, tempFile, tempFolder);
+
+ var files = _fileSystem.GetFilePaths(tempFolder, true)
+ .ToList();
+
+ foreach (var file in files.Where(i =>
+ {
+ var filename = Path.GetFileName(i);
+
+ return
+ string.Equals(filename, downloadinfo.FFProbeFilename, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(filename, downloadinfo.FFMpegFilename, StringComparison.OrdinalIgnoreCase);
+ }))
+ {
+ var targetFile = Path.Combine(targetFolder, Path.GetFileName(file));
+ _fileSystem.CopyFile(file, targetFile, true);
+ SetFilePermissions(targetFile);
+ }
+ }
+ finally
+ {
+ DeleteFile(tempFile);
+ }
+ }
+
+ private void SetFilePermissions(string path)
+ {
+ _fileSystem.SetExecutable(path);
+ }
+
+ private void ExtractArchive(FFMpegInstallInfo downloadinfo, string archivePath, string targetPath)
+ {
+ _logger.Info("Extracting {0} to {1}", archivePath, targetPath);
+
+ if (string.Equals(downloadinfo.ArchiveType, "7z", StringComparison.OrdinalIgnoreCase))
+ {
+ _zipClient.ExtractAllFrom7z(archivePath, targetPath, true);
+ }
+ else if (string.Equals(downloadinfo.ArchiveType, "gz", StringComparison.OrdinalIgnoreCase))
+ {
+ _zipClient.ExtractAllFromTar(archivePath, targetPath, true);
+ }
+ }
+
+ private void DeleteFile(string path)
+ {
+ try
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error deleting temp file {0}", ex, path);
+ }
+ }
+
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs b/Emby.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs
new file mode 100644
index 000000000..5bb21d02a
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs
@@ -0,0 +1,839 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Library;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Naming.TV;
+using EpisodeInfo = MediaBrowser.Controller.Providers.EpisodeInfo;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public class EpisodeFileOrganizer
+ {
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IFileOrganizationService _organizationService;
+ private readonly IServerConfigurationManager _config;
+ private readonly IProviderManager _providerManager;
+
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ public EpisodeFileOrganizer(IFileOrganizationService organizationService, IServerConfigurationManager config, IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager)
+ {
+ _organizationService = organizationService;
+ _config = config;
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _libraryMonitor = libraryMonitor;
+ _providerManager = providerManager;
+ }
+
+ public async Task<FileOrganizationResult> OrganizeEpisodeFile(string path, AutoOrganizeOptions options, bool overwriteExisting, CancellationToken cancellationToken)
+ {
+ _logger.Info("Sorting file {0}", path);
+
+ var result = new FileOrganizationResult
+ {
+ Date = DateTime.UtcNow,
+ OriginalPath = path,
+ OriginalFileName = Path.GetFileName(path),
+ Type = FileOrganizerType.Episode,
+ FileSize = _fileSystem.GetFileInfo(path).Length
+ };
+
+ try
+ {
+ if (_libraryMonitor.IsPathLocked(path))
+ {
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = "Path is locked by other processes. Please try again later.";
+ return result;
+ }
+
+ var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions();
+ var resolver = new EpisodeResolver(namingOptions, new NullLogger());
+
+ var episodeInfo = resolver.Resolve(path, false) ??
+ new MediaBrowser.Naming.TV.EpisodeInfo();
+
+ var seriesName = episodeInfo.SeriesName;
+
+ if (!string.IsNullOrEmpty(seriesName))
+ {
+ var seasonNumber = episodeInfo.SeasonNumber;
+
+ result.ExtractedSeasonNumber = seasonNumber;
+
+ // Passing in true will include a few extra regex's
+ var episodeNumber = episodeInfo.EpisodeNumber;
+
+ result.ExtractedEpisodeNumber = episodeNumber;
+
+ var premiereDate = episodeInfo.IsByDate ?
+ new DateTime(episodeInfo.Year.Value, episodeInfo.Month.Value, episodeInfo.Day.Value) :
+ (DateTime?)null;
+
+ if (episodeInfo.IsByDate || (seasonNumber.HasValue && episodeNumber.HasValue))
+ {
+ if (episodeInfo.IsByDate)
+ {
+ _logger.Debug("Extracted information from {0}. Series name {1}, Date {2}", path, seriesName, premiereDate.Value);
+ }
+ else
+ {
+ _logger.Debug("Extracted information from {0}. Series name {1}, Season {2}, Episode {3}", path, seriesName, seasonNumber, episodeNumber);
+ }
+
+ var endingEpisodeNumber = episodeInfo.EndingEpsiodeNumber;
+
+ result.ExtractedEndingEpisodeNumber = endingEpisodeNumber;
+
+ await OrganizeEpisode(path,
+ seriesName,
+ seasonNumber,
+ episodeNumber,
+ endingEpisodeNumber,
+ premiereDate,
+ options,
+ overwriteExisting,
+ false,
+ result,
+ cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ var msg = string.Format("Unable to determine episode number from {0}", path);
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = msg;
+ _logger.Warn(msg);
+ }
+ }
+ else
+ {
+ var msg = string.Format("Unable to determine series name from {0}", path);
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = msg;
+ _logger.Warn(msg);
+ }
+
+ var previousResult = _organizationService.GetResultBySourcePath(path);
+
+ if (previousResult != null)
+ {
+ // Don't keep saving the same result over and over if nothing has changed
+ if (previousResult.Status == result.Status && previousResult.StatusMessage == result.StatusMessage && result.Status != FileSortingStatus.Success)
+ {
+ return previousResult;
+ }
+ }
+
+ await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = ex.Message;
+ }
+
+ return result;
+ }
+
+ public async Task<FileOrganizationResult> OrganizeWithCorrection(EpisodeFileOrganizationRequest request, AutoOrganizeOptions options, CancellationToken cancellationToken)
+ {
+ var result = _organizationService.GetResult(request.ResultId);
+
+ try
+ {
+ Series series = null;
+
+ if (request.NewSeriesProviderIds.Count > 0)
+ {
+ // We're having a new series here
+ SeriesInfo seriesRequest = new SeriesInfo();
+ seriesRequest.ProviderIds = request.NewSeriesProviderIds;
+
+ var refreshOptions = new MetadataRefreshOptions(_fileSystem);
+ series = new Series();
+ series.Id = Guid.NewGuid();
+ series.Name = request.NewSeriesName;
+
+ int year;
+ if (int.TryParse(request.NewSeriesYear, out year))
+ {
+ series.ProductionYear = year;
+ }
+
+ var seriesFolderName = series.Name;
+ if (series.ProductionYear.HasValue)
+ {
+ seriesFolderName = string.Format("{0} ({1})", seriesFolderName, series.ProductionYear);
+ }
+
+ series.Path = Path.Combine(request.TargetFolder, seriesFolderName);
+
+ series.ProviderIds = request.NewSeriesProviderIds;
+
+ await series.RefreshMetadata(refreshOptions, cancellationToken);
+ }
+
+ if (series == null)
+ {
+ // Existing Series
+ series = (Series)_libraryManager.GetItemById(new Guid(request.SeriesId));
+ }
+
+ await OrganizeEpisode(result.OriginalPath,
+ series,
+ request.SeasonNumber,
+ request.EpisodeNumber,
+ request.EndingEpisodeNumber,
+ null,
+ options,
+ true,
+ request.RememberCorrection,
+ result,
+ cancellationToken).ConfigureAwait(false);
+
+ await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = ex.Message;
+ }
+
+ return result;
+ }
+
+ private Task OrganizeEpisode(string sourcePath,
+ string seriesName,
+ int? seasonNumber,
+ int? episodeNumber,
+ int? endingEpiosdeNumber,
+ DateTime? premiereDate,
+ AutoOrganizeOptions options,
+ bool overwriteExisting,
+ bool rememberCorrection,
+ FileOrganizationResult result,
+ CancellationToken cancellationToken)
+ {
+ var series = GetMatchingSeries(seriesName, result, options);
+
+ if (series == null)
+ {
+ var msg = string.Format("Unable to find series in library matching name {0}", seriesName);
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = msg;
+ _logger.Warn(msg);
+ return Task.FromResult(true);
+ }
+
+ return OrganizeEpisode(sourcePath,
+ series,
+ seasonNumber,
+ episodeNumber,
+ endingEpiosdeNumber,
+ premiereDate,
+ options,
+ overwriteExisting,
+ rememberCorrection,
+ result,
+ cancellationToken);
+ }
+
+ private async Task OrganizeEpisode(string sourcePath,
+ Series series,
+ int? seasonNumber,
+ int? episodeNumber,
+ int? endingEpiosdeNumber,
+ DateTime? premiereDate,
+ AutoOrganizeOptions options,
+ bool overwriteExisting,
+ bool rememberCorrection,
+ FileOrganizationResult result,
+ CancellationToken cancellationToken)
+ {
+ _logger.Info("Sorting file {0} into series {1}", sourcePath, series.Path);
+
+ var originalExtractedSeriesString = result.ExtractedName;
+
+ bool isNew = string.IsNullOrWhiteSpace(result.Id);
+
+ if (isNew)
+ {
+ await _organizationService.SaveResult(result, cancellationToken);
+ }
+
+ if (!_organizationService.AddToInProgressList(result, isNew))
+ {
+ throw new Exception("File is currently processed otherwise. Please try again later.");
+ }
+
+ try
+ {
+ // Proceed to sort the file
+ var newPath = await GetNewPath(sourcePath, series, seasonNumber, episodeNumber, endingEpiosdeNumber, premiereDate, options.TvOptions, cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrEmpty(newPath))
+ {
+ var msg = string.Format("Unable to sort {0} because target path could not be determined.", sourcePath);
+ throw new Exception(msg);
+ }
+
+ _logger.Info("Sorting file {0} to new path {1}", sourcePath, newPath);
+ result.TargetPath = newPath;
+
+ var fileExists = _fileSystem.FileExists(result.TargetPath);
+ var otherDuplicatePaths = GetOtherDuplicatePaths(result.TargetPath, series, seasonNumber, episodeNumber, endingEpiosdeNumber);
+
+ if (!overwriteExisting)
+ {
+ if (options.TvOptions.CopyOriginalFile && fileExists && IsSameEpisode(sourcePath, newPath))
+ {
+ var msg = string.Format("File '{0}' already copied to new path '{1}', stopping organization", sourcePath, newPath);
+ _logger.Info(msg);
+ result.Status = FileSortingStatus.SkippedExisting;
+ result.StatusMessage = msg;
+ return;
+ }
+
+ if (fileExists)
+ {
+ var msg = string.Format("File '{0}' already exists as '{1}', stopping organization", sourcePath, newPath);
+ _logger.Info(msg);
+ result.Status = FileSortingStatus.SkippedExisting;
+ result.StatusMessage = msg;
+ result.TargetPath = newPath;
+ return;
+ }
+
+ if (otherDuplicatePaths.Count > 0)
+ {
+ var msg = string.Format("File '{0}' already exists as these:'{1}'. Stopping organization", sourcePath, string.Join("', '", otherDuplicatePaths));
+ _logger.Info(msg);
+ result.Status = FileSortingStatus.SkippedExisting;
+ result.StatusMessage = msg;
+ result.DuplicatePaths = otherDuplicatePaths;
+ return;
+ }
+ }
+
+ PerformFileSorting(options.TvOptions, result);
+
+ if (overwriteExisting)
+ {
+ var hasRenamedFiles = false;
+
+ foreach (var path in otherDuplicatePaths)
+ {
+ _logger.Debug("Removing duplicate episode {0}", path);
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ var renameRelatedFiles = !hasRenamedFiles &&
+ string.Equals(Path.GetDirectoryName(path), Path.GetDirectoryName(result.TargetPath), StringComparison.OrdinalIgnoreCase);
+
+ if (renameRelatedFiles)
+ {
+ hasRenamedFiles = true;
+ }
+
+ try
+ {
+ DeleteLibraryFile(path, renameRelatedFiles, result.TargetPath);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error removing duplicate episode", ex, path);
+ }
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(path, true);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = ex.Message;
+ _logger.Warn(ex.Message);
+ return;
+ }
+ finally
+ {
+ _organizationService.RemoveFromInprogressList(result);
+ }
+
+ if (rememberCorrection)
+ {
+ SaveSmartMatchString(originalExtractedSeriesString, series, options);
+ }
+ }
+
+ private void SaveSmartMatchString(string matchString, Series series, AutoOrganizeOptions options)
+ {
+ if (string.IsNullOrEmpty(matchString) || matchString.Length < 3)
+ {
+ return;
+ }
+
+ SmartMatchInfo info = options.SmartMatchInfos.FirstOrDefault(i => string.Equals(i.ItemName, series.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (info == null)
+ {
+ info = new SmartMatchInfo();
+ info.ItemName = series.Name;
+ info.OrganizerType = FileOrganizerType.Episode;
+ info.DisplayName = series.Name;
+ var list = options.SmartMatchInfos.ToList();
+ list.Add(info);
+ options.SmartMatchInfos = list.ToArray();
+ }
+
+ if (!info.MatchStrings.Contains(matchString, StringComparer.OrdinalIgnoreCase))
+ {
+ var list = info.MatchStrings.ToList();
+ list.Add(matchString);
+ info.MatchStrings = list.ToArray();
+ _config.SaveAutoOrganizeOptions(options);
+ }
+ }
+
+ private void DeleteLibraryFile(string path, bool renameRelatedFiles, string targetPath)
+ {
+ _fileSystem.DeleteFile(path);
+
+ if (!renameRelatedFiles)
+ {
+ return;
+ }
+
+ // Now find other files
+ var originalFilenameWithoutExtension = Path.GetFileNameWithoutExtension(path);
+ var directory = Path.GetDirectoryName(path);
+
+ if (!string.IsNullOrWhiteSpace(originalFilenameWithoutExtension) && !string.IsNullOrWhiteSpace(directory))
+ {
+ // Get all related files, e.g. metadata, images, etc
+ var files = _fileSystem.GetFilePaths(directory)
+ .Where(i => (Path.GetFileNameWithoutExtension(i) ?? string.Empty).StartsWith(originalFilenameWithoutExtension, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ var targetFilenameWithoutExtension = Path.GetFileNameWithoutExtension(targetPath);
+
+ foreach (var file in files)
+ {
+ directory = Path.GetDirectoryName(file);
+ var filename = Path.GetFileName(file);
+
+ filename = filename.Replace(originalFilenameWithoutExtension, targetFilenameWithoutExtension,
+ StringComparison.OrdinalIgnoreCase);
+
+ var destination = Path.Combine(directory, filename);
+
+ _fileSystem.MoveFile(file, destination);
+ }
+ }
+ }
+
+ private List<string> GetOtherDuplicatePaths(string targetPath,
+ Series series,
+ int? seasonNumber,
+ int? episodeNumber,
+ int? endingEpisodeNumber)
+ {
+ // TODO: Support date-naming?
+ if (!seasonNumber.HasValue || !episodeNumber.HasValue)
+ {
+ return new List<string>();
+ }
+
+ var episodePaths = series.GetRecursiveChildren()
+ .OfType<Episode>()
+ .Where(i =>
+ {
+ var locationType = i.LocationType;
+
+ // Must be file system based and match exactly
+ if (locationType != LocationType.Remote &&
+ locationType != LocationType.Virtual &&
+ i.ParentIndexNumber.HasValue &&
+ i.ParentIndexNumber.Value == seasonNumber &&
+ i.IndexNumber.HasValue &&
+ i.IndexNumber.Value == episodeNumber)
+ {
+
+ if (endingEpisodeNumber.HasValue || i.IndexNumberEnd.HasValue)
+ {
+ return endingEpisodeNumber.HasValue && i.IndexNumberEnd.HasValue &&
+ endingEpisodeNumber.Value == i.IndexNumberEnd.Value;
+ }
+
+ return true;
+ }
+
+ return false;
+ })
+ .Select(i => i.Path)
+ .ToList();
+
+ var folder = Path.GetDirectoryName(targetPath);
+ var targetFileNameWithoutExtension = _fileSystem.GetFileNameWithoutExtension(targetPath);
+
+ try
+ {
+ var filesOfOtherExtensions = _fileSystem.GetFilePaths(folder)
+ .Where(i => _libraryManager.IsVideoFile(i) && string.Equals(_fileSystem.GetFileNameWithoutExtension(i), targetFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase));
+
+ episodePaths.AddRange(filesOfOtherExtensions);
+ }
+ catch (IOException)
+ {
+ // No big deal. Maybe the season folder doesn't already exist.
+ }
+
+ return episodePaths.Where(i => !string.Equals(i, targetPath, StringComparison.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private void PerformFileSorting(TvFileOrganizationOptions options, FileOrganizationResult result)
+ {
+ // We should probably handle this earlier so that we never even make it this far
+ if (string.Equals(result.OriginalPath, result.TargetPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(result.TargetPath);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(result.TargetPath));
+
+ var targetAlreadyExists = _fileSystem.FileExists(result.TargetPath);
+
+ try
+ {
+ if (targetAlreadyExists || options.CopyOriginalFile)
+ {
+ _fileSystem.CopyFile(result.OriginalPath, result.TargetPath, true);
+ }
+ else
+ {
+ _fileSystem.MoveFile(result.OriginalPath, result.TargetPath);
+ }
+
+ result.Status = FileSortingStatus.Success;
+ result.StatusMessage = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ var errorMsg = string.Format("Failed to move file from {0} to {1}: {2}", result.OriginalPath, result.TargetPath, ex.Message);
+
+ result.Status = FileSortingStatus.Failure;
+ result.StatusMessage = errorMsg;
+ _logger.ErrorException(errorMsg, ex);
+
+ return;
+ }
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(result.TargetPath, true);
+ }
+
+ if (targetAlreadyExists && !options.CopyOriginalFile)
+ {
+ try
+ {
+ _fileSystem.DeleteFile(result.OriginalPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath);
+ }
+ }
+ }
+
+ private Series GetMatchingSeries(string seriesName, FileOrganizationResult result, AutoOrganizeOptions options)
+ {
+ var parsedName = _libraryManager.ParseName(seriesName);
+
+ var yearInName = parsedName.Year;
+ var nameWithoutYear = parsedName.Name;
+
+ result.ExtractedName = nameWithoutYear;
+ result.ExtractedYear = yearInName;
+
+ var series = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ Recursive = true
+ })
+ .Cast<Series>()
+ .Select(i => NameUtils.GetMatchScore(nameWithoutYear, yearInName, i))
+ .Where(i => i.Item2 > 0)
+ .OrderByDescending(i => i.Item2)
+ .Select(i => i.Item1)
+ .FirstOrDefault();
+
+ if (series == null)
+ {
+ SmartMatchInfo info = options.SmartMatchInfos.FirstOrDefault(e => e.MatchStrings.Contains(nameWithoutYear, StringComparer.OrdinalIgnoreCase));
+
+ if (info != null)
+ {
+ series = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ Recursive = true,
+ Name = info.ItemName
+
+ }).Cast<Series>().FirstOrDefault();
+ }
+ }
+
+ return series;
+ }
+
+ /// <summary>
+ /// Gets the new path.
+ /// </summary>
+ /// <param name="sourcePath">The source path.</param>
+ /// <param name="series">The series.</param>
+ /// <param name="seasonNumber">The season number.</param>
+ /// <param name="episodeNumber">The episode number.</param>
+ /// <param name="endingEpisodeNumber">The ending episode number.</param>
+ /// <param name="premiereDate">The premiere date.</param>
+ /// <param name="options">The options.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>System.String.</returns>
+ private async Task<string> GetNewPath(string sourcePath,
+ Series series,
+ int? seasonNumber,
+ int? episodeNumber,
+ int? endingEpisodeNumber,
+ DateTime? premiereDate,
+ TvFileOrganizationOptions options,
+ CancellationToken cancellationToken)
+ {
+ var episodeInfo = new EpisodeInfo
+ {
+ IndexNumber = episodeNumber,
+ IndexNumberEnd = endingEpisodeNumber,
+ MetadataCountryCode = series.GetPreferredMetadataCountryCode(),
+ MetadataLanguage = series.GetPreferredMetadataLanguage(),
+ ParentIndexNumber = seasonNumber,
+ SeriesProviderIds = series.ProviderIds,
+ PremiereDate = premiereDate
+ };
+
+ var searchResults = await _providerManager.GetRemoteSearchResults<Episode, EpisodeInfo>(new RemoteSearchQuery<EpisodeInfo>
+ {
+ SearchInfo = episodeInfo
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ var episode = searchResults.FirstOrDefault();
+
+ if (episode == null)
+ {
+ var msg = string.Format("No provider metadata found for {0} season {1} episode {2}", series.Name, seasonNumber, episodeNumber);
+ _logger.Warn(msg);
+ throw new Exception(msg);
+ }
+
+ var episodeName = episode.Name;
+
+ //if (string.IsNullOrWhiteSpace(episodeName))
+ //{
+ // var msg = string.Format("No provider metadata found for {0} season {1} episode {2}", series.Name, seasonNumber, episodeNumber);
+ // _logger.Warn(msg);
+ // return null;
+ //}
+
+ seasonNumber = seasonNumber ?? episode.ParentIndexNumber;
+ episodeNumber = episodeNumber ?? episode.IndexNumber;
+
+ var newPath = GetSeasonFolderPath(series, seasonNumber.Value, options);
+
+ // MAX_PATH - trailing <NULL> charachter - drive component: 260 - 1 - 3 = 256
+ // Usually newPath would include the drive component, but use 256 to be sure
+ var maxFilenameLength = 256 - newPath.Length;
+
+ if (!newPath.EndsWith(@"\"))
+ {
+ // Remove 1 for missing backslash combining path and filename
+ maxFilenameLength--;
+ }
+
+ // Remove additional 4 chars to prevent PathTooLongException for downloaded subtitles (eg. filename.ext.eng.srt)
+ maxFilenameLength -= 4;
+
+ var episodeFileName = GetEpisodeFileName(sourcePath, series.Name, seasonNumber.Value, episodeNumber.Value, endingEpisodeNumber, episodeName, options, maxFilenameLength);
+
+ if (string.IsNullOrEmpty(episodeFileName))
+ {
+ // cause failure
+ return string.Empty;
+ }
+
+ newPath = Path.Combine(newPath, episodeFileName);
+
+ return newPath;
+ }
+
+ /// <summary>
+ /// Gets the season folder path.
+ /// </summary>
+ /// <param name="series">The series.</param>
+ /// <param name="seasonNumber">The season number.</param>
+ /// <param name="options">The options.</param>
+ /// <returns>System.String.</returns>
+ private string GetSeasonFolderPath(Series series, int seasonNumber, TvFileOrganizationOptions options)
+ {
+ // If there's already a season folder, use that
+ var season = series
+ .GetRecursiveChildren(i => i is Season && i.LocationType == LocationType.FileSystem && i.IndexNumber.HasValue && i.IndexNumber.Value == seasonNumber)
+ .FirstOrDefault();
+
+ if (season != null)
+ {
+ return season.Path;
+ }
+
+ var path = series.Path;
+
+ if (series.ContainsEpisodesWithoutSeasonFolders)
+ {
+ return path;
+ }
+
+ if (seasonNumber == 0)
+ {
+ return Path.Combine(path, _fileSystem.GetValidFilename(options.SeasonZeroFolderName));
+ }
+
+ var seasonFolderName = options.SeasonFolderPattern
+ .Replace("%s", seasonNumber.ToString(_usCulture))
+ .Replace("%0s", seasonNumber.ToString("00", _usCulture))
+ .Replace("%00s", seasonNumber.ToString("000", _usCulture));
+
+ return Path.Combine(path, _fileSystem.GetValidFilename(seasonFolderName));
+ }
+
+ private string GetEpisodeFileName(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, string episodeTitle, TvFileOrganizationOptions options, int? maxLength)
+ {
+ seriesName = _fileSystem.GetValidFilename(seriesName).Trim();
+
+ if (string.IsNullOrWhiteSpace(episodeTitle))
+ {
+ episodeTitle = string.Empty;
+ }
+ else
+ {
+ episodeTitle = _fileSystem.GetValidFilename(episodeTitle).Trim();
+ }
+
+ var sourceExtension = (Path.GetExtension(sourcePath) ?? string.Empty).TrimStart('.');
+
+ var pattern = endingEpisodeNumber.HasValue ? options.MultiEpisodeNamePattern : options.EpisodeNamePattern;
+
+ if (string.IsNullOrWhiteSpace(pattern))
+ {
+ throw new Exception("GetEpisodeFileName: Configured episode name pattern is empty!");
+ }
+
+ var result = pattern.Replace("%sn", seriesName)
+ .Replace("%s.n", seriesName.Replace(" ", "."))
+ .Replace("%s_n", seriesName.Replace(" ", "_"))
+ .Replace("%s", seasonNumber.ToString(_usCulture))
+ .Replace("%0s", seasonNumber.ToString("00", _usCulture))
+ .Replace("%00s", seasonNumber.ToString("000", _usCulture))
+ .Replace("%ext", sourceExtension)
+ .Replace("%en", "%#1")
+ .Replace("%e.n", "%#2")
+ .Replace("%e_n", "%#3");
+
+ if (endingEpisodeNumber.HasValue)
+ {
+ result = result.Replace("%ed", endingEpisodeNumber.Value.ToString(_usCulture))
+ .Replace("%0ed", endingEpisodeNumber.Value.ToString("00", _usCulture))
+ .Replace("%00ed", endingEpisodeNumber.Value.ToString("000", _usCulture));
+ }
+
+ result = result.Replace("%e", episodeNumber.ToString(_usCulture))
+ .Replace("%0e", episodeNumber.ToString("00", _usCulture))
+ .Replace("%00e", episodeNumber.ToString("000", _usCulture));
+
+ if (maxLength.HasValue && result.Contains("%#"))
+ {
+ // Substract 3 for the temp token length (%#1, %#2 or %#3)
+ int maxRemainingTitleLength = maxLength.Value - result.Length + 3;
+ string shortenedEpisodeTitle = string.Empty;
+
+ if (maxRemainingTitleLength > 5)
+ {
+ // A title with fewer than 5 letters wouldn't be of much value
+ shortenedEpisodeTitle = episodeTitle.Substring(0, Math.Min(maxRemainingTitleLength, episodeTitle.Length));
+ }
+
+ result = result.Replace("%#1", shortenedEpisodeTitle)
+ .Replace("%#2", shortenedEpisodeTitle.Replace(" ", "."))
+ .Replace("%#3", shortenedEpisodeTitle.Replace(" ", "_"));
+ }
+
+ if (maxLength.HasValue && result.Length > maxLength.Value)
+ {
+ // There may be cases where reducing the title length may still not be sufficient to
+ // stay below maxLength
+ var msg = string.Format("Unable to generate an episode file name shorter than {0} characters to constrain to the max path limit", maxLength);
+ throw new Exception(msg);
+ }
+
+ return result;
+ }
+
+ private bool IsSameEpisode(string sourcePath, string newPath)
+ {
+ try
+ {
+ var sourceFileInfo = _fileSystem.GetFileInfo(sourcePath);
+ var destinationFileInfo = _fileSystem.GetFileInfo(newPath);
+
+ if (sourceFileInfo.Length == destinationFileInfo.Length)
+ {
+ return true;
+ }
+ }
+ catch (FileNotFoundException)
+ {
+ return false;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/Extensions.cs b/Emby.Server.Implementations/FileOrganization/Extensions.cs
new file mode 100644
index 000000000..506bc0327
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/Extensions.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.FileOrganization;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public static class ConfigurationExtension
+ {
+ public static AutoOrganizeOptions GetAutoOrganizeOptions(this IConfigurationManager manager)
+ {
+ return manager.GetConfiguration<AutoOrganizeOptions>("autoorganize");
+ }
+ public static void SaveAutoOrganizeOptions(this IConfigurationManager manager, AutoOrganizeOptions options)
+ {
+ manager.SaveConfiguration("autoorganize", options);
+ }
+ }
+
+ public class AutoOrganizeOptionsFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ Key = "autoorganize",
+ ConfigurationType = typeof (AutoOrganizeOptions)
+ }
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/FileOrganizationNotifier.cs b/Emby.Server.Implementations/FileOrganization/FileOrganizationNotifier.cs
new file mode 100644
index 000000000..2a0176547
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/FileOrganizationNotifier.cs
@@ -0,0 +1,80 @@
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ /// <summary>
+ /// Class SessionInfoWebSocketListener
+ /// </summary>
+ class FileOrganizationNotifier : IServerEntryPoint
+ {
+ private readonly IFileOrganizationService _organizationService;
+ private readonly ISessionManager _sessionManager;
+ private readonly ITaskManager _taskManager;
+
+ public FileOrganizationNotifier(ILogger logger, IFileOrganizationService organizationService, ISessionManager sessionManager, ITaskManager taskManager)
+ {
+ _organizationService = organizationService;
+ _sessionManager = sessionManager;
+ _taskManager = taskManager;
+ }
+
+ public void Run()
+ {
+ _organizationService.ItemAdded += _organizationService_ItemAdded;
+ _organizationService.ItemRemoved += _organizationService_ItemRemoved;
+ _organizationService.ItemUpdated += _organizationService_ItemUpdated;
+ _organizationService.LogReset += _organizationService_LogReset;
+
+ //_taskManager.TaskCompleted += _taskManager_TaskCompleted;
+ }
+
+ private void _organizationService_LogReset(object sender, EventArgs e)
+ {
+ _sessionManager.SendMessageToAdminSessions("AutoOrganize_LogReset", (FileOrganizationResult)null, CancellationToken.None);
+ }
+
+ private void _organizationService_ItemUpdated(object sender, GenericEventArgs<FileOrganizationResult> e)
+ {
+ _sessionManager.SendMessageToAdminSessions("AutoOrganize_ItemUpdated", e.Argument, CancellationToken.None);
+ }
+
+ private void _organizationService_ItemRemoved(object sender, GenericEventArgs<FileOrganizationResult> e)
+ {
+ _sessionManager.SendMessageToAdminSessions("AutoOrganize_ItemRemoved", e.Argument, CancellationToken.None);
+ }
+
+ private void _organizationService_ItemAdded(object sender, GenericEventArgs<FileOrganizationResult> e)
+ {
+ _sessionManager.SendMessageToAdminSessions("AutoOrganize_ItemAdded", e.Argument, CancellationToken.None);
+ }
+
+ //private void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e)
+ //{
+ // var taskWithKey = e.Task.ScheduledTask as IHasKey;
+ // if (taskWithKey != null && taskWithKey.Key == "AutoOrganize")
+ // {
+ // _sessionManager.SendMessageToAdminSessions("AutoOrganize_TaskCompleted", (FileOrganizationResult)null, CancellationToken.None);
+ // }
+ //}
+
+ public void Dispose()
+ {
+ _organizationService.ItemAdded -= _organizationService_ItemAdded;
+ _organizationService.ItemRemoved -= _organizationService_ItemRemoved;
+ _organizationService.ItemUpdated -= _organizationService_ItemUpdated;
+ _organizationService.LogReset -= _organizationService_LogReset;
+
+ //_taskManager.TaskCompleted -= _taskManager_TaskCompleted;
+ }
+
+
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/FileOrganizationService.cs b/Emby.Server.Implementations/FileOrganization/FileOrganizationService.cs
new file mode 100644
index 000000000..4094e6b9b
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/FileOrganizationService.cs
@@ -0,0 +1,283 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public class FileOrganizationService : IFileOrganizationService
+ {
+ private readonly ITaskManager _taskManager;
+ private readonly IFileOrganizationRepository _repo;
+ private readonly ILogger _logger;
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+ private readonly IProviderManager _providerManager;
+ private readonly ConcurrentDictionary<string, bool> _inProgressItemIds = new ConcurrentDictionary<string, bool>();
+
+ public event EventHandler<GenericEventArgs<FileOrganizationResult>> ItemAdded;
+ public event EventHandler<GenericEventArgs<FileOrganizationResult>> ItemUpdated;
+ public event EventHandler<GenericEventArgs<FileOrganizationResult>> ItemRemoved;
+ public event EventHandler LogReset;
+
+ public FileOrganizationService(ITaskManager taskManager, IFileOrganizationRepository repo, ILogger logger, ILibraryMonitor libraryMonitor, ILibraryManager libraryManager, IServerConfigurationManager config, IFileSystem fileSystem, IProviderManager providerManager)
+ {
+ _taskManager = taskManager;
+ _repo = repo;
+ _logger = logger;
+ _libraryMonitor = libraryMonitor;
+ _libraryManager = libraryManager;
+ _config = config;
+ _fileSystem = fileSystem;
+ _providerManager = providerManager;
+ }
+
+ public void BeginProcessNewFiles()
+ {
+ _taskManager.CancelIfRunningAndQueue<OrganizerScheduledTask>();
+ }
+
+ public Task SaveResult(FileOrganizationResult result, CancellationToken cancellationToken)
+ {
+ if (result == null || string.IsNullOrEmpty(result.OriginalPath))
+ {
+ throw new ArgumentNullException("result");
+ }
+
+ result.Id = result.OriginalPath.GetMD5().ToString("N");
+
+ return _repo.SaveResult(result, cancellationToken);
+ }
+
+ public QueryResult<FileOrganizationResult> GetResults(FileOrganizationResultQuery query)
+ {
+ var results = _repo.GetResults(query);
+
+ foreach (var result in results.Items)
+ {
+ result.IsInProgress = _inProgressItemIds.ContainsKey(result.Id);
+ }
+
+ return results;
+ }
+
+ public FileOrganizationResult GetResult(string id)
+ {
+ var result = _repo.GetResult(id);
+
+ if (result != null)
+ {
+ result.IsInProgress = _inProgressItemIds.ContainsKey(result.Id);
+ }
+
+ return result;
+ }
+
+ public FileOrganizationResult GetResultBySourcePath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ var id = path.GetMD5().ToString("N");
+
+ return GetResult(id);
+ }
+
+ public async Task DeleteOriginalFile(string resultId)
+ {
+ var result = _repo.GetResult(resultId);
+
+ _logger.Info("Requested to delete {0}", result.OriginalPath);
+
+ if (!AddToInProgressList(result, false))
+ {
+ throw new Exception("Path is currently processed otherwise. Please try again later.");
+ }
+
+ try
+ {
+ _fileSystem.DeleteFile(result.OriginalPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath);
+ }
+ finally
+ {
+ RemoveFromInprogressList(result);
+ }
+
+ await _repo.Delete(resultId);
+
+ EventHelper.FireEventIfNotNull(ItemRemoved, this, new GenericEventArgs<FileOrganizationResult>(result), _logger);
+ }
+
+ private AutoOrganizeOptions GetAutoOrganizeOptions()
+ {
+ return _config.GetAutoOrganizeOptions();
+ }
+
+ public async Task PerformOrganization(string resultId)
+ {
+ var result = _repo.GetResult(resultId);
+
+ if (string.IsNullOrEmpty(result.TargetPath))
+ {
+ throw new ArgumentException("No target path available.");
+ }
+
+ var organizer = new EpisodeFileOrganizer(this, _config, _fileSystem, _logger, _libraryManager,
+ _libraryMonitor, _providerManager);
+
+ var organizeResult = await organizer.OrganizeEpisodeFile(result.OriginalPath, GetAutoOrganizeOptions(), true, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (organizeResult.Status != FileSortingStatus.Success)
+ {
+ throw new Exception(result.StatusMessage);
+ }
+ }
+
+ public async Task ClearLog()
+ {
+ await _repo.DeleteAll();
+ EventHelper.FireEventIfNotNull(LogReset, this, EventArgs.Empty, _logger);
+ }
+
+ public async Task PerformEpisodeOrganization(EpisodeFileOrganizationRequest request)
+ {
+ var organizer = new EpisodeFileOrganizer(this, _config, _fileSystem, _logger, _libraryManager,
+ _libraryMonitor, _providerManager);
+
+ var result = await organizer.OrganizeWithCorrection(request, GetAutoOrganizeOptions(), CancellationToken.None).ConfigureAwait(false);
+
+ if (result.Status != FileSortingStatus.Success)
+ {
+ throw new Exception(result.StatusMessage);
+ }
+ }
+
+ public QueryResult<SmartMatchInfo> GetSmartMatchInfos(FileOrganizationResultQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ var options = GetAutoOrganizeOptions();
+
+ var items = options.SmartMatchInfos.Skip(query.StartIndex ?? 0).Take(query.Limit ?? Int32.MaxValue).ToArray();
+
+ return new QueryResult<SmartMatchInfo>()
+ {
+ Items = items,
+ TotalRecordCount = options.SmartMatchInfos.Length
+ };
+ }
+
+ public void DeleteSmartMatchEntry(string itemName, string matchString)
+ {
+ if (string.IsNullOrEmpty(itemName))
+ {
+ throw new ArgumentNullException("itemName");
+ }
+
+ if (string.IsNullOrEmpty(matchString))
+ {
+ throw new ArgumentNullException("matchString");
+ }
+
+ var options = GetAutoOrganizeOptions();
+
+ SmartMatchInfo info = options.SmartMatchInfos.FirstOrDefault(i => string.Equals(i.ItemName, itemName));
+
+ if (info != null && info.MatchStrings.Contains(matchString))
+ {
+ var list = info.MatchStrings.ToList();
+ list.Remove(matchString);
+ info.MatchStrings = list.ToArray();
+
+ if (info.MatchStrings.Length == 0)
+ {
+ var infos = options.SmartMatchInfos.ToList();
+ infos.Remove(info);
+ options.SmartMatchInfos = infos.ToArray();
+ }
+
+ _config.SaveAutoOrganizeOptions(options);
+ }
+ }
+
+ /// <summary>
+ /// Attempts to add a an item to the list of currently processed items.
+ /// </summary>
+ /// <param name="result">The result item.</param>
+ /// <param name="isNewItem">Passing true will notify the client to reload all items, otherwise only a single item will be refreshed.</param>
+ /// <returns>True if the item was added, False if the item is already contained in the list.</returns>
+ public bool AddToInProgressList(FileOrganizationResult result, bool isNewItem)
+ {
+ if (string.IsNullOrWhiteSpace(result.Id))
+ {
+ result.Id = result.OriginalPath.GetMD5().ToString("N");
+ }
+
+ if (!_inProgressItemIds.TryAdd(result.Id, false))
+ {
+ return false;
+ }
+
+ result.IsInProgress = true;
+
+ if (isNewItem)
+ {
+ EventHelper.FireEventIfNotNull(ItemAdded, this, new GenericEventArgs<FileOrganizationResult>(result), _logger);
+ }
+ else
+ {
+ EventHelper.FireEventIfNotNull(ItemUpdated, this, new GenericEventArgs<FileOrganizationResult>(result), _logger);
+ }
+
+ return true;
+ }
+
+ /// <summary>
+ /// Removes an item from the list of currently processed items.
+ /// </summary>
+ /// <param name="result">The result item.</param>
+ /// <returns>True if the item was removed, False if the item was not contained in the list.</returns>
+ public bool RemoveFromInprogressList(FileOrganizationResult result)
+ {
+ bool itemValue;
+ var retval = _inProgressItemIds.TryRemove(result.Id, out itemValue);
+
+ result.IsInProgress = false;
+
+ EventHelper.FireEventIfNotNull(ItemUpdated, this, new GenericEventArgs<FileOrganizationResult>(result), _logger);
+
+ return retval;
+ }
+
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/NameUtils.cs b/Emby.Server.Implementations/FileOrganization/NameUtils.cs
new file mode 100644
index 000000000..eb22ca4ea
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/NameUtils.cs
@@ -0,0 +1,81 @@
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Controller.Entities;
+using System;
+using System.Globalization;
+using MediaBrowser.Controller.Extensions;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public static class NameUtils
+ {
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+ internal static Tuple<T, int> GetMatchScore<T>(string sortedName, int? year, T series)
+ where T : BaseItem
+ {
+ var score = 0;
+
+ var seriesNameWithoutYear = series.Name;
+ if (series.ProductionYear.HasValue)
+ {
+ seriesNameWithoutYear = seriesNameWithoutYear.Replace(series.ProductionYear.Value.ToString(UsCulture), String.Empty);
+ }
+
+ if (IsNameMatch(sortedName, seriesNameWithoutYear))
+ {
+ score++;
+
+ if (year.HasValue && series.ProductionYear.HasValue)
+ {
+ if (year.Value == series.ProductionYear.Value)
+ {
+ score++;
+ }
+ else
+ {
+ // Regardless of name, return a 0 score if the years don't match
+ return new Tuple<T, int>(series, 0);
+ }
+ }
+ }
+
+ return new Tuple<T, int>(series, score);
+ }
+
+
+ private static bool IsNameMatch(string name1, string name2)
+ {
+ name1 = GetComparableName(name1);
+ name2 = GetComparableName(name2);
+
+ return String.Equals(name1, name2, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string GetComparableName(string name)
+ {
+ name = name.RemoveDiacritics();
+
+ name = " " + name + " ";
+
+ name = name.Replace(".", " ")
+ .Replace("_", " ")
+ .Replace(" and ", " ")
+ .Replace(".and.", " ")
+ .Replace("&", " ")
+ .Replace("!", " ")
+ .Replace("(", " ")
+ .Replace(")", " ")
+ .Replace(":", " ")
+ .Replace(",", " ")
+ .Replace("-", " ")
+ .Replace("'", " ")
+ .Replace("[", " ")
+ .Replace("]", " ")
+ .Replace(" a ", String.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace(" the ", String.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace(" ", String.Empty);
+
+ return name.Trim();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs b/Emby.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs
new file mode 100644
index 000000000..5be7ba7ad
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs
@@ -0,0 +1,101 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public class OrganizerScheduledTask : IScheduledTask, IConfigurableScheduledTask
+ {
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerConfigurationManager _config;
+ private readonly IFileOrganizationService _organizationService;
+ private readonly IProviderManager _providerManager;
+
+ public OrganizerScheduledTask(ILibraryMonitor libraryMonitor, ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IServerConfigurationManager config, IFileOrganizationService organizationService, IProviderManager providerManager)
+ {
+ _libraryMonitor = libraryMonitor;
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _config = config;
+ _organizationService = organizationService;
+ _providerManager = providerManager;
+ }
+
+ public string Name
+ {
+ get { return "Organize new media files"; }
+ }
+
+ public string Description
+ {
+ get { return "Processes new files available in the configured watch folder."; }
+ }
+
+ public string Category
+ {
+ get { return "Library"; }
+ }
+
+ private AutoOrganizeOptions GetAutoOrganizeOptions()
+ {
+ return _config.GetAutoOrganizeOptions();
+ }
+
+ public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ if (GetAutoOrganizeOptions().TvOptions.IsEnabled)
+ {
+ await new TvFolderOrganizer(_libraryManager, _logger, _fileSystem, _libraryMonitor, _organizationService, _config, _providerManager)
+ .Organize(GetAutoOrganizeOptions(), cancellationToken, progress).ConfigureAwait(false);
+ }
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromMinutes(5).Ticks}
+ };
+ }
+
+ public bool IsHidden
+ {
+ get { return !GetAutoOrganizeOptions().TvOptions.IsEnabled; }
+ }
+
+ public bool IsEnabled
+ {
+ get { return GetAutoOrganizeOptions().TvOptions.IsEnabled; }
+ }
+
+ public bool IsLogged
+ {
+ get { return false; }
+ }
+
+ public string Key
+ {
+ get { return "AutoOrganize"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/FileOrganization/TvFolderOrganizer.cs b/Emby.Server.Implementations/FileOrganization/TvFolderOrganizer.cs
new file mode 100644
index 000000000..2850c3a61
--- /dev/null
+++ b/Emby.Server.Implementations/FileOrganization/TvFolderOrganizer.cs
@@ -0,0 +1,210 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.FileOrganization
+{
+ public class TvFolderOrganizer
+ {
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IFileOrganizationService _organizationService;
+ private readonly IServerConfigurationManager _config;
+ private readonly IProviderManager _providerManager;
+
+ public TvFolderOrganizer(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IFileOrganizationService organizationService, IServerConfigurationManager config, IProviderManager providerManager)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _libraryMonitor = libraryMonitor;
+ _organizationService = organizationService;
+ _config = config;
+ _providerManager = providerManager;
+ }
+
+ private bool EnableOrganization(FileSystemMetadata fileInfo, TvFileOrganizationOptions options)
+ {
+ var minFileBytes = options.MinFileSizeMb * 1024 * 1024;
+
+ try
+ {
+ return _libraryManager.IsVideoFile(fileInfo.FullName) && fileInfo.Length >= minFileBytes;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error organizing file {0}", ex, fileInfo.Name);
+ }
+
+ return false;
+ }
+
+ public async Task Organize(AutoOrganizeOptions options, CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var watchLocations = options.TvOptions.WatchLocations.ToList();
+
+ var eligibleFiles = watchLocations.SelectMany(GetFilesToOrganize)
+ .OrderBy(_fileSystem.GetCreationTimeUtc)
+ .Where(i => EnableOrganization(i, options.TvOptions))
+ .ToList();
+
+ var processedFolders = new HashSet<string>();
+
+ progress.Report(10);
+
+ if (eligibleFiles.Count > 0)
+ {
+ var numComplete = 0;
+
+ foreach (var file in eligibleFiles)
+ {
+ var organizer = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager,
+ _libraryMonitor, _providerManager);
+
+ try
+ {
+ var result = await organizer.OrganizeEpisodeFile(file.FullName, options, options.TvOptions.OverwriteExistingEpisodes, cancellationToken).ConfigureAwait(false);
+ if (result.Status == FileSortingStatus.Success && !processedFolders.Contains(file.DirectoryName, StringComparer.OrdinalIgnoreCase))
+ {
+ processedFolders.Add(file.DirectoryName);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error organizing episode {0}", ex, file.FullName);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= eligibleFiles.Count;
+
+ progress.Report(10 + 89 * percent);
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ progress.Report(99);
+
+ foreach (var path in processedFolders)
+ {
+ var deleteExtensions = options.TvOptions.LeftOverFileExtensionsToDelete
+ .Select(i => i.Trim().TrimStart('.'))
+ .Where(i => !string.IsNullOrEmpty(i))
+ .Select(i => "." + i)
+ .ToList();
+
+ if (deleteExtensions.Count > 0)
+ {
+ DeleteLeftOverFiles(path, deleteExtensions);
+ }
+
+ if (options.TvOptions.DeleteEmptyFolders)
+ {
+ if (!IsWatchFolder(path, watchLocations))
+ {
+ DeleteEmptyFolders(path);
+ }
+ }
+ }
+
+ progress.Report(100);
+ }
+
+ /// <summary>
+ /// Gets the files to organize.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns>IEnumerable{FileInfo}.</returns>
+ private List<FileSystemMetadata> GetFilesToOrganize(string path)
+ {
+ try
+ {
+ return _fileSystem.GetFiles(path, true)
+ .ToList();
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error getting files from {0}", ex, path);
+
+ return new List<FileSystemMetadata>();
+ }
+ }
+
+ /// <summary>
+ /// Deletes the left over files.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="extensions">The extensions.</param>
+ private void DeleteLeftOverFiles(string path, IEnumerable<string> extensions)
+ {
+ var eligibleFiles = _fileSystem.GetFiles(path, true)
+ .Where(i => extensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var file in eligibleFiles)
+ {
+ try
+ {
+ _fileSystem.DeleteFile(file.FullName);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting file {0}", ex, file.FullName);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Deletes the empty folders.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ private void DeleteEmptyFolders(string path)
+ {
+ try
+ {
+ foreach (var d in _fileSystem.GetDirectoryPaths(path))
+ {
+ DeleteEmptyFolders(d);
+ }
+
+ var entries = _fileSystem.GetFileSystemEntryPaths(path);
+
+ if (!entries.Any())
+ {
+ try
+ {
+ _logger.Debug("Deleting empty directory {0}", path);
+ _fileSystem.DeleteDirectory(path, false);
+ }
+ catch (UnauthorizedAccessException) { }
+ catch (IOException) { }
+ }
+ }
+ catch (UnauthorizedAccessException) { }
+ }
+
+ /// <summary>
+ /// Determines if a given folder path is contained in a folder list
+ /// </summary>
+ /// <param name="path">The folder path to check.</param>
+ /// <param name="watchLocations">A list of folders.</param>
+ private bool IsWatchFolder(string path, IEnumerable<string> watchLocations)
+ {
+ return watchLocations.Contains(path, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/GetSwaggerResource.cs b/Emby.Server.Implementations/HttpServer/GetSwaggerResource.cs
new file mode 100644
index 000000000..819ede1ab
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/GetSwaggerResource.cs
@@ -0,0 +1,17 @@
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ /// <summary>
+ /// Class GetDashboardResource
+ /// </summary>
+ [Route("/swagger-ui/{ResourceName*}", "GET")]
+ public class GetSwaggerResource
+ {
+ /// <summary>
+ /// Gets or sets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string ResourceName { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
new file mode 100644
index 000000000..0e1f5a551
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
@@ -0,0 +1,729 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Logging;
+using ServiceStack;
+using ServiceStack.Host;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.HttpServer;
+using Emby.Server.Implementations.HttpServer.SocketSharp;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.System;
+using MediaBrowser.Model.Text;
+using SocketHttpListener.Net;
+using SocketHttpListener.Primitives;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public class HttpListenerHost : ServiceStackHost, IHttpServer
+ {
+ private string DefaultRedirectPath { get; set; }
+
+ private readonly ILogger _logger;
+ public IEnumerable<string> UrlPrefixes { get; private set; }
+
+ private readonly List<IService> _restServices = new List<IService>();
+
+ private IHttpListener _listener;
+
+ public event EventHandler<WebSocketConnectEventArgs> WebSocketConnected;
+ public event EventHandler<WebSocketConnectingEventArgs> WebSocketConnecting;
+
+ private readonly IServerConfigurationManager _config;
+ private readonly INetworkManager _networkManager;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ private readonly IServerApplicationHost _appHost;
+
+ private readonly ITextEncoding _textEncoding;
+ private readonly ISocketFactory _socketFactory;
+ private readonly ICryptoProvider _cryptoProvider;
+
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IXmlSerializer _xmlSerializer;
+ private readonly ICertificate _certificate;
+ private readonly IEnvironmentInfo _environment;
+ private readonly IStreamFactory _streamFactory;
+ private readonly Func<Type, Func<string, object>> _funcParseFn;
+ private readonly bool _enableDualModeSockets;
+
+ public HttpListenerHost(IServerApplicationHost applicationHost,
+ ILogger logger,
+ IServerConfigurationManager config,
+ string serviceName,
+ string defaultRedirectPath, INetworkManager networkManager, IMemoryStreamFactory memoryStreamProvider, ITextEncoding textEncoding, ISocketFactory socketFactory, ICryptoProvider cryptoProvider, IJsonSerializer jsonSerializer, IXmlSerializer xmlSerializer, IEnvironmentInfo environment, ICertificate certificate, IStreamFactory streamFactory, Func<Type, Func<string, object>> funcParseFn, bool enableDualModeSockets)
+ : base(serviceName)
+ {
+ _appHost = applicationHost;
+ DefaultRedirectPath = defaultRedirectPath;
+ _networkManager = networkManager;
+ _memoryStreamProvider = memoryStreamProvider;
+ _textEncoding = textEncoding;
+ _socketFactory = socketFactory;
+ _cryptoProvider = cryptoProvider;
+ _jsonSerializer = jsonSerializer;
+ _xmlSerializer = xmlSerializer;
+ _environment = environment;
+ _certificate = certificate;
+ _streamFactory = streamFactory;
+ _funcParseFn = funcParseFn;
+ _enableDualModeSockets = enableDualModeSockets;
+ _config = config;
+
+ _logger = logger;
+ }
+
+ public string GlobalResponse { get; set; }
+
+ readonly Dictionary<Type, int> _mapExceptionToStatusCode = new Dictionary<Type, int>
+ {
+ {typeof (InvalidOperationException), 500},
+ {typeof (NotImplementedException), 500},
+ {typeof (ResourceNotFoundException), 404},
+ {typeof (FileNotFoundException), 404},
+ //{typeof (DirectoryNotFoundException), 404},
+ {typeof (SecurityException), 401},
+ {typeof (PaymentRequiredException), 402},
+ {typeof (UnauthorizedAccessException), 500},
+ {typeof (PlatformNotSupportedException), 500},
+ {typeof (NotSupportedException), 500}
+ };
+
+ public override void Configure()
+ {
+ var requestFilters = _appHost.GetExports<IRequestFilter>().ToList();
+ foreach (var filter in requestFilters)
+ {
+ GlobalRequestFilters.Add(filter.Filter);
+ }
+
+ GlobalResponseFilters.Add(new ResponseFilter(_logger).FilterResponse);
+ }
+
+ protected override ILogger Logger
+ {
+ get
+ {
+ return _logger;
+ }
+ }
+
+ public override T Resolve<T>()
+ {
+ return _appHost.Resolve<T>();
+ }
+
+ public override T TryResolve<T>()
+ {
+ return _appHost.TryResolve<T>();
+ }
+
+ public override object CreateInstance(Type type)
+ {
+ return _appHost.CreateInstance(type);
+ }
+
+ protected override ServiceController CreateServiceController()
+ {
+ var types = _restServices.Select(r => r.GetType()).ToArray();
+
+ return new ServiceController(() => types);
+ }
+
+ public override ServiceStackHost Start(string listeningAtUrlBase)
+ {
+ StartListener();
+ return this;
+ }
+
+ /// <summary>
+ /// Starts the Web Service
+ /// </summary>
+ private void StartListener()
+ {
+ WebSocketSharpRequest.HandlerFactoryPath = GetHandlerPathIfAny(UrlPrefixes.First());
+
+ _listener = GetListener();
+
+ _listener.WebSocketConnected = OnWebSocketConnected;
+ _listener.WebSocketConnecting = OnWebSocketConnecting;
+ _listener.ErrorHandler = ErrorHandler;
+ _listener.RequestHandler = RequestHandler;
+
+ _listener.Start(UrlPrefixes);
+ }
+
+ public static string GetHandlerPathIfAny(string listenerUrl)
+ {
+ if (listenerUrl == null) return null;
+ var pos = listenerUrl.IndexOf("://", StringComparison.OrdinalIgnoreCase);
+ if (pos == -1) return null;
+ var startHostUrl = listenerUrl.Substring(pos + "://".Length);
+ var endPos = startHostUrl.IndexOf('/');
+ if (endPos == -1) return null;
+ var endHostUrl = startHostUrl.Substring(endPos + 1);
+ return string.IsNullOrEmpty(endHostUrl) ? null : endHostUrl.TrimEnd('/');
+ }
+
+ private IHttpListener GetListener()
+ {
+ return new WebSocketSharpListener(_logger,
+ _certificate,
+ _memoryStreamProvider,
+ _textEncoding,
+ _networkManager,
+ _socketFactory,
+ _cryptoProvider,
+ _streamFactory,
+ _enableDualModeSockets,
+ GetRequest);
+ }
+
+ private IHttpRequest GetRequest(HttpListenerContext httpContext)
+ {
+ var operationName = httpContext.Request.GetOperationName();
+
+ var req = new WebSocketSharpRequest(httpContext, operationName, _logger, _memoryStreamProvider);
+
+ return req;
+ }
+
+ private void OnWebSocketConnecting(WebSocketConnectingEventArgs args)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (WebSocketConnecting != null)
+ {
+ WebSocketConnecting(this, args);
+ }
+ }
+
+ private void OnWebSocketConnected(WebSocketConnectEventArgs args)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (WebSocketConnected != null)
+ {
+ WebSocketConnected(this, args);
+ }
+ }
+
+ private void ErrorHandler(Exception ex, IRequest httpReq)
+ {
+ try
+ {
+ _logger.ErrorException("Error processing request", ex);
+
+ var httpRes = httpReq.Response;
+
+ if (httpRes.IsClosed)
+ {
+ return;
+ }
+
+ int statusCode;
+ if (!_mapExceptionToStatusCode.TryGetValue(ex.GetType(), out statusCode))
+ {
+ statusCode = 500;
+ }
+ httpRes.StatusCode = statusCode;
+
+ httpRes.ContentType = "text/html";
+ Write(httpRes, ex.Message);
+ }
+ catch
+ {
+ //_logger.ErrorException("Error this.ProcessRequest(context)(Exception while writing error to the response)", errorEx);
+ }
+ }
+
+ /// <summary>
+ /// Shut down the Web Service
+ /// </summary>
+ public void Stop()
+ {
+ if (_listener != null)
+ {
+ _listener.Stop();
+ }
+ }
+
+ private readonly Dictionary<string, int> _skipLogExtensions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
+ {
+ {".js", 0},
+ {".css", 0},
+ {".woff", 0},
+ {".woff2", 0},
+ {".ttf", 0},
+ {".html", 0}
+ };
+
+ private bool EnableLogging(string url, string localPath)
+ {
+ var extension = GetExtension(url);
+
+ if (string.IsNullOrWhiteSpace(extension) || !_skipLogExtensions.ContainsKey(extension))
+ {
+ if (string.IsNullOrWhiteSpace(localPath) || localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private string GetExtension(string url)
+ {
+ var parts = url.Split(new[] { '?' }, 2);
+
+ return Path.GetExtension(parts[0]);
+ }
+
+ public static string RemoveQueryStringByKey(string url, string key)
+ {
+ var uri = new Uri(url);
+
+ // this gets all the query string key value pairs as a collection
+ var newQueryString = MyHttpUtility.ParseQueryString(uri.Query);
+
+ var originalCount = newQueryString.Count;
+
+ if (originalCount == 0)
+ {
+ return url;
+ }
+
+ // this removes the key if exists
+ newQueryString.Remove(key);
+
+ if (originalCount == newQueryString.Count)
+ {
+ return url;
+ }
+
+ // this gets the page path from root without QueryString
+ string pagePathWithoutQueryString = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0];
+
+ return newQueryString.Count > 0
+ ? String.Format("{0}?{1}", pagePathWithoutQueryString, newQueryString)
+ : pagePathWithoutQueryString;
+ }
+
+ private string GetUrlToLog(string url)
+ {
+ url = RemoveQueryStringByKey(url, "api_key");
+
+ return url;
+ }
+
+ private string NormalizeConfiguredLocalAddress(string address)
+ {
+ var index = address.Trim('/').IndexOf('/');
+
+ if (index != -1)
+ {
+ address = address.Substring(index + 1);
+ }
+
+ return address.Trim('/');
+ }
+
+ private bool ValidateHost(Uri url)
+ {
+ var hosts = _config
+ .Configuration
+ .LocalNetworkAddresses
+ .Select(NormalizeConfiguredLocalAddress)
+ .ToList();
+
+ if (hosts.Count == 0)
+ {
+ return true;
+ }
+
+ var host = url.Host ?? string.Empty;
+
+ _logger.Debug("Validating host {0}", host);
+
+ if (_networkManager.IsInPrivateAddressSpace(host))
+ {
+ hosts.Add("localhost");
+ hosts.Add("127.0.0.1");
+
+ return hosts.Any(i => host.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1);
+ }
+
+ return true;
+ }
+
+ /// <summary>
+ /// Overridable method that can be used to implement a custom hnandler
+ /// </summary>
+ /// <param name="httpReq">The HTTP req.</param>
+ /// <param name="url">The URL.</param>
+ /// <returns>Task.</returns>
+ protected async Task RequestHandler(IHttpRequest httpReq, Uri url)
+ {
+ var date = DateTime.Now;
+ var httpRes = httpReq.Response;
+ bool enableLog = false;
+ string urlToLog = null;
+ string remoteIp = null;
+
+ try
+ {
+ if (_disposed)
+ {
+ httpRes.StatusCode = 503;
+ httpRes.ContentType = "text/plain";
+ Write(httpRes, "Server shutting down");
+ return;
+ }
+
+ if (!ValidateHost(url))
+ {
+ httpRes.StatusCode = 400;
+ httpRes.ContentType = "text/plain";
+ Write(httpRes, "Invalid host");
+ return;
+ }
+
+ if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase))
+ {
+ httpRes.StatusCode = 200;
+ httpRes.AddHeader("Access-Control-Allow-Origin", "*");
+ httpRes.AddHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
+ httpRes.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization");
+ httpRes.ContentType = "text/plain";
+ Write(httpRes, string.Empty);
+ return;
+ }
+
+ var operationName = httpReq.OperationName;
+ var localPath = url.LocalPath;
+
+ var urlString = url.OriginalString;
+ enableLog = EnableLogging(urlString, localPath);
+ urlToLog = urlString;
+
+ if (enableLog)
+ {
+ urlToLog = GetUrlToLog(urlString);
+ remoteIp = httpReq.RemoteIp;
+
+ LoggerUtils.LogRequest(_logger, urlToLog, httpReq.HttpMethod, httpReq.UserAgent);
+ }
+
+ if (string.Equals(localPath, "/emby/", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(localPath, "/mediabrowser/", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, DefaultRedirectPath);
+ return;
+ }
+ if (string.Equals(localPath, "/emby", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(localPath, "/mediabrowser", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, "emby/" + DefaultRedirectPath);
+ return;
+ }
+
+ if (string.Equals(localPath, "/mediabrowser/", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(localPath, "/mediabrowser", StringComparison.OrdinalIgnoreCase) ||
+ localPath.IndexOf("mediabrowser/web", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ httpRes.StatusCode = 200;
+ httpRes.ContentType = "text/html";
+ var newUrl = urlString.Replace("mediabrowser", "emby", StringComparison.OrdinalIgnoreCase)
+ .Replace("/dashboard/", "/web/", StringComparison.OrdinalIgnoreCase);
+
+ if (!string.Equals(newUrl, urlString, StringComparison.OrdinalIgnoreCase))
+ {
+ Write(httpRes,
+ "<!doctype html><html><head><title>Emby</title></head><body>Please update your Emby bookmark to <a href=\"" +
+ newUrl + "\">" + newUrl + "</a></body></html>");
+ return;
+ }
+ }
+
+ if (localPath.IndexOf("dashboard/", StringComparison.OrdinalIgnoreCase) != -1 &&
+ localPath.IndexOf("web/dashboard", StringComparison.OrdinalIgnoreCase) == -1)
+ {
+ httpRes.StatusCode = 200;
+ httpRes.ContentType = "text/html";
+ var newUrl = urlString.Replace("mediabrowser", "emby", StringComparison.OrdinalIgnoreCase)
+ .Replace("/dashboard/", "/web/", StringComparison.OrdinalIgnoreCase);
+
+ if (!string.Equals(newUrl, urlString, StringComparison.OrdinalIgnoreCase))
+ {
+ Write(httpRes,
+ "<!doctype html><html><head><title>Emby</title></head><body>Please update your Emby bookmark to <a href=\"" +
+ newUrl + "\">" + newUrl + "</a></body></html>");
+ return;
+ }
+ }
+
+ if (string.Equals(localPath, "/web", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, DefaultRedirectPath);
+ return;
+ }
+ if (string.Equals(localPath, "/web/", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, "../" + DefaultRedirectPath);
+ return;
+ }
+ if (string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, DefaultRedirectPath);
+ return;
+ }
+ if (string.IsNullOrEmpty(localPath))
+ {
+ RedirectToUrl(httpRes, "/" + DefaultRedirectPath);
+ return;
+ }
+
+ if (string.Equals(localPath, "/emby/pin", StringComparison.OrdinalIgnoreCase))
+ {
+ RedirectToUrl(httpRes, "web/pin.html");
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(GlobalResponse))
+ {
+ httpRes.StatusCode = 503;
+ httpRes.ContentType = "text/html";
+ Write(httpRes, GlobalResponse);
+ return;
+ }
+
+ var handler = HttpHandlerFactory.GetHandler(httpReq, _logger);
+
+ if (handler != null)
+ {
+ await handler.ProcessRequestAsync(httpReq, httpRes, operationName).ConfigureAwait(false);
+ }
+ else
+ {
+ ErrorHandler(new FileNotFoundException(), httpReq);
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorHandler(ex, httpReq);
+ }
+ finally
+ {
+ httpRes.Close();
+
+ if (enableLog)
+ {
+ var statusCode = httpRes.StatusCode;
+
+ var duration = DateTime.Now - date;
+
+ LoggerUtils.LogResponse(_logger, statusCode, urlToLog, remoteIp, duration);
+ }
+ }
+ }
+
+ private void Write(IResponse response, string text)
+ {
+ var bOutput = Encoding.UTF8.GetBytes(text);
+ response.SetContentLength(bOutput.Length);
+
+ var outputStream = response.OutputStream;
+ outputStream.Write(bOutput, 0, bOutput.Length);
+ }
+
+ public static void RedirectToUrl(IResponse httpRes, string url)
+ {
+ httpRes.StatusCode = 302;
+ httpRes.AddHeader("Location", url);
+ }
+
+
+ /// <summary>
+ /// Adds the rest handlers.
+ /// </summary>
+ /// <param name="services">The services.</param>
+ public void Init(IEnumerable<IService> services)
+ {
+ _restServices.AddRange(services);
+
+ ServiceController = CreateServiceController();
+
+ _logger.Info("Calling ServiceStack AppHost.Init");
+
+ base.Init();
+ }
+
+ public override RouteAttribute[] GetRouteAttributes(Type requestType)
+ {
+ var routes = base.GetRouteAttributes(requestType).ToList();
+ var clone = routes.ToList();
+
+ foreach (var route in clone)
+ {
+ routes.Add(new RouteAttribute(NormalizeEmbyRoutePath(route.Path), route.Verbs)
+ {
+ Notes = route.Notes,
+ Priority = route.Priority,
+ Summary = route.Summary
+ });
+
+ routes.Add(new RouteAttribute(NormalizeRoutePath(route.Path), route.Verbs)
+ {
+ Notes = route.Notes,
+ Priority = route.Priority,
+ Summary = route.Summary
+ });
+
+ routes.Add(new RouteAttribute(DoubleNormalizeEmbyRoutePath(route.Path), route.Verbs)
+ {
+ Notes = route.Notes,
+ Priority = route.Priority,
+ Summary = route.Summary
+ });
+ }
+
+ return routes.ToArray();
+ }
+
+ public override object GetTaskResult(Task task, string requestName)
+ {
+ try
+ {
+ var taskObject = task as Task<object>;
+ if (taskObject != null)
+ {
+ return taskObject.Result;
+ }
+
+ task.Wait();
+
+ var type = task.GetType().GetTypeInfo();
+ if (!type.IsGenericType)
+ {
+ return null;
+ }
+
+ Logger.Warn("Getting task result from " + requestName + " using reflection. For better performance have your api return Task<object>");
+ return type.GetDeclaredProperty("Result").GetValue(task);
+ }
+ catch (TypeAccessException)
+ {
+ return null; //return null for void Task's
+ }
+ }
+
+ public override Func<string, object> GetParseFn(Type propertyType)
+ {
+ return _funcParseFn(propertyType);
+ }
+
+ public override void SerializeToJson(object o, Stream stream)
+ {
+ _jsonSerializer.SerializeToStream(o, stream);
+ }
+
+ public override void SerializeToXml(object o, Stream stream)
+ {
+ _xmlSerializer.SerializeToStream(o, stream);
+ }
+
+ public override object DeserializeXml(Type type, Stream stream)
+ {
+ return _xmlSerializer.DeserializeFromStream(type, stream);
+ }
+
+ public override object DeserializeJson(Type type, Stream stream)
+ {
+ return _jsonSerializer.DeserializeFromStream(stream, type);
+ }
+
+ private string NormalizeEmbyRoutePath(string path)
+ {
+ if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase))
+ {
+ return "/emby" + path;
+ }
+
+ return "emby/" + path;
+ }
+
+ private string DoubleNormalizeEmbyRoutePath(string path)
+ {
+ if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase))
+ {
+ return "/emby/emby" + path;
+ }
+
+ return "emby/emby/" + path;
+ }
+
+ private string NormalizeRoutePath(string path)
+ {
+ if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase))
+ {
+ return "/mediabrowser" + path;
+ }
+
+ return "mediabrowser/" + path;
+ }
+
+ private bool _disposed;
+ private readonly object _disposeLock = new object();
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed) return;
+ base.Dispose();
+
+ lock (_disposeLock)
+ {
+ if (_disposed) return;
+
+ if (disposing)
+ {
+ Stop();
+ }
+
+ //release unmanaged resources here...
+ _disposed = true;
+ }
+ }
+
+ public override void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public void StartServer(IEnumerable<string> urlPrefixes)
+ {
+ UrlPrefixes = urlPrefixes.ToList();
+ Start(UrlPrefixes.First());
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs b/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs
new file mode 100644
index 000000000..995dc7b7b
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs
@@ -0,0 +1,830 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.IO.Compression;
+using System.Net;
+using System.Runtime.Serialization;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using Emby.Server.Implementations.HttpServer;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Services;
+using ServiceStack;
+using ServiceStack.Host;
+using IRequest = MediaBrowser.Model.Services.IRequest;
+using MimeTypes = MediaBrowser.Model.Net.MimeTypes;
+using StreamWriter = Emby.Server.Implementations.HttpServer.StreamWriter;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ /// <summary>
+ /// Class HttpResultFactory
+ /// </summary>
+ public class HttpResultFactory : IHttpResultFactory
+ {
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IMemoryStreamFactory _memoryStreamFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="HttpResultFactory" /> class.
+ /// </summary>
+ public HttpResultFactory(ILogManager logManager, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IMemoryStreamFactory memoryStreamFactory)
+ {
+ _fileSystem = fileSystem;
+ _jsonSerializer = jsonSerializer;
+ _memoryStreamFactory = memoryStreamFactory;
+ _logger = logManager.GetLogger("HttpResultFactory");
+ }
+
+ /// <summary>
+ /// Gets the result.
+ /// </summary>
+ /// <param name="content">The content.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ /// <returns>System.Object.</returns>
+ public object GetResult(object content, string contentType, IDictionary<string, string> responseHeaders = null)
+ {
+ return GetHttpResult(content, contentType, true, responseHeaders);
+ }
+
+ /// <summary>
+ /// Gets the HTTP result.
+ /// </summary>
+ private IHasHeaders GetHttpResult(object content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
+ {
+ IHasHeaders result;
+
+ var stream = content as Stream;
+
+ if (stream != null)
+ {
+ result = new StreamWriter(stream, contentType, _logger);
+ }
+
+ else
+ {
+ var bytes = content as byte[];
+
+ if (bytes != null)
+ {
+ result = new StreamWriter(bytes, contentType, _logger);
+ }
+ else
+ {
+ var text = content as string;
+
+ if (text != null)
+ {
+ result = new StreamWriter(Encoding.UTF8.GetBytes(text), contentType, _logger);
+ }
+ else
+ {
+ result = new HttpResult(content, contentType, HttpStatusCode.OK);
+ }
+ }
+ }
+ if (responseHeaders == null)
+ {
+ responseHeaders = new Dictionary<string, string>();
+ }
+
+ string expires;
+ if (addCachePrevention && !responseHeaders.TryGetValue("Expires", out expires))
+ {
+ responseHeaders["Expires"] = "-1";
+ }
+
+ AddResponseHeaders(result, responseHeaders);
+
+ return result;
+ }
+
+ /// <summary>
+ /// Gets the optimized result.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="result">The result.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ /// <returns>System.Object.</returns>
+ /// <exception cref="System.ArgumentNullException">result</exception>
+ public object GetOptimizedResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null)
+ where T : class
+ {
+ return GetOptimizedResultInternal<T>(requestContext, result, true, responseHeaders);
+ }
+
+ private object GetOptimizedResultInternal<T>(IRequest requestContext, T result, bool addCachePrevention, IDictionary<string, string> responseHeaders = null)
+ where T : class
+ {
+ if (result == null)
+ {
+ throw new ArgumentNullException("result");
+ }
+
+ var optimizedResult = ToOptimizedResult(requestContext, result);
+
+ if (responseHeaders == null)
+ {
+ responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (addCachePrevention)
+ {
+ responseHeaders["Expires"] = "-1";
+ }
+
+ // Apply headers
+ var hasHeaders = optimizedResult as IHasHeaders;
+
+ if (hasHeaders != null)
+ {
+ AddResponseHeaders(hasHeaders, responseHeaders);
+ }
+
+ return optimizedResult;
+ }
+
+ public static string GetCompressionType(IRequest request)
+ {
+ var acceptEncoding = request.Headers["Accept-Encoding"];
+
+ if (!string.IsNullOrWhiteSpace(acceptEncoding))
+ {
+ if (acceptEncoding.Contains("deflate"))
+ return "deflate";
+
+ if (acceptEncoding.Contains("gzip"))
+ return "gzip";
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Returns the optimized result for the IRequestContext.
+ /// Does not use or store results in any cache.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="dto"></param>
+ /// <returns></returns>
+ public object ToOptimizedResult<T>(IRequest request, T dto)
+ {
+ var compressionType = GetCompressionType(request);
+ if (compressionType == null)
+ {
+ var contentType = request.ResponseContentType;
+
+ switch (GetRealContentType(contentType))
+ {
+ case "application/xml":
+ case "text/xml":
+ case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml
+ return SerializeToXmlString(dto);
+
+ case "application/json":
+ case "text/json":
+ return _jsonSerializer.SerializeToString(dto);
+ }
+ }
+
+ // Do not use the memoryStreamFactory here, they don't place nice with compression
+ using (var ms = new MemoryStream())
+ {
+ using (var compressionStream = GetCompressionStream(ms, compressionType))
+ {
+ ContentTypes.Instance.SerializeToStream(request, dto, compressionStream);
+ compressionStream.Dispose();
+
+ var compressedBytes = ms.ToArray();
+
+ var httpResult = new StreamWriter(compressedBytes, request.ResponseContentType, _logger);
+
+ //httpResult.Headers["Content-Length"] = compressedBytes.Length.ToString(UsCulture);
+ httpResult.Headers["Content-Encoding"] = compressionType;
+
+ return httpResult;
+ }
+ }
+ }
+
+ private static Stream GetCompressionStream(Stream outputStream, string compressionType)
+ {
+ if (compressionType == "deflate")
+ return new DeflateStream(outputStream, CompressionMode.Compress, true);
+ if (compressionType == "gzip")
+ return new GZipStream(outputStream, CompressionMode.Compress, true);
+
+ throw new NotSupportedException(compressionType);
+ }
+
+ public static string GetRealContentType(string contentType)
+ {
+ return contentType == null
+ ? null
+ : contentType.Split(';')[0].ToLower().Trim();
+ }
+
+ private string SerializeToXmlString(object from)
+ {
+ using (var ms = new MemoryStream())
+ {
+ var xwSettings = new XmlWriterSettings();
+ xwSettings.Encoding = new UTF8Encoding(false);
+ xwSettings.OmitXmlDeclaration = false;
+
+ using (var xw = XmlWriter.Create(ms, xwSettings))
+ {
+ var serializer = new DataContractSerializer(from.GetType());
+ serializer.WriteObject(xw, from);
+ xw.Flush();
+ ms.Seek(0, SeekOrigin.Begin);
+ var reader = new StreamReader(ms);
+ return reader.ReadToEnd();
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the optimized result using cache.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ /// <param name="factoryFn">The factory fn.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ /// <returns>System.Object.</returns>
+ /// <exception cref="System.ArgumentNullException">cacheKey
+ /// or
+ /// factoryFn</exception>
+ public object GetOptimizedResultUsingCache<T>(IRequest requestContext, Guid cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, Func<T> factoryFn, IDictionary<string, string> responseHeaders = null)
+ where T : class
+ {
+ if (cacheKey == Guid.Empty)
+ {
+ throw new ArgumentNullException("cacheKey");
+ }
+ if (factoryFn == null)
+ {
+ throw new ArgumentNullException("factoryFn");
+ }
+
+ var key = cacheKey.ToString("N");
+
+ if (responseHeaders == null)
+ {
+ responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+ }
+
+ // See if the result is already cached in the browser
+ var result = GetCachedResult(requestContext, responseHeaders, cacheKey, key, lastDateModified, cacheDuration, null);
+
+ if (result != null)
+ {
+ return result;
+ }
+
+ return GetOptimizedResultInternal(requestContext, factoryFn(), false, responseHeaders);
+ }
+
+ /// <summary>
+ /// To the cached result.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ /// <param name="factoryFn">The factory fn.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ /// <returns>System.Object.</returns>
+ /// <exception cref="System.ArgumentNullException">cacheKey</exception>
+ public object GetCachedResult<T>(IRequest requestContext, Guid cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration, Func<T> factoryFn, string contentType, IDictionary<string, string> responseHeaders = null)
+ where T : class
+ {
+ if (cacheKey == Guid.Empty)
+ {
+ throw new ArgumentNullException("cacheKey");
+ }
+ if (factoryFn == null)
+ {
+ throw new ArgumentNullException("factoryFn");
+ }
+
+ var key = cacheKey.ToString("N");
+
+ if (responseHeaders == null)
+ {
+ responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+ }
+
+ // See if the result is already cached in the browser
+ var result = GetCachedResult(requestContext, responseHeaders, cacheKey, key, lastDateModified, cacheDuration, contentType);
+
+ if (result != null)
+ {
+ return result;
+ }
+
+ result = factoryFn();
+
+ // Apply caching headers
+ var hasHeaders = result as IHasHeaders;
+
+ if (hasHeaders != null)
+ {
+ AddResponseHeaders(hasHeaders, responseHeaders);
+ return hasHeaders;
+ }
+
+ return GetHttpResult(result, contentType, false, responseHeaders);
+ }
+
+ /// <summary>
+ /// Pres the process optimized result.
+ /// </summary>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="responseHeaders">The responseHeaders.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="cacheKeyString">The cache key string.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <returns>System.Object.</returns>
+ private object GetCachedResult(IRequest requestContext, IDictionary<string, string> responseHeaders, Guid cacheKey, string cacheKeyString, DateTime? lastDateModified, TimeSpan? cacheDuration, string contentType)
+ {
+ responseHeaders["ETag"] = string.Format("\"{0}\"", cacheKeyString);
+
+ if (IsNotModified(requestContext, cacheKey, lastDateModified, cacheDuration))
+ {
+ AddAgeHeader(responseHeaders, lastDateModified);
+ AddExpiresHeader(responseHeaders, cacheKeyString, cacheDuration);
+
+ var result = new HttpResult(new byte[] { }, contentType ?? "text/html", HttpStatusCode.NotModified);
+
+ AddResponseHeaders(result, responseHeaders);
+
+ return result;
+ }
+
+ AddCachingHeaders(responseHeaders, cacheKeyString, lastDateModified, cacheDuration);
+
+ return null;
+ }
+
+ public Task<object> GetStaticFileResult(IRequest requestContext,
+ string path,
+ FileShareMode fileShare = FileShareMode.Read)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ return GetStaticFileResult(requestContext, new StaticFileResultOptions
+ {
+ Path = path,
+ FileShare = fileShare
+ });
+ }
+
+ public Task<object> GetStaticFileResult(IRequest requestContext,
+ StaticFileResultOptions options)
+ {
+ var path = options.Path;
+ var fileShare = options.FileShare;
+
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ if (fileShare != FileShareMode.Read && fileShare != FileShareMode.ReadWrite)
+ {
+ throw new ArgumentException("FileShare must be either Read or ReadWrite");
+ }
+
+ if (string.IsNullOrWhiteSpace(options.ContentType))
+ {
+ options.ContentType = MimeTypes.GetMimeType(path);
+ }
+
+ if (!options.DateLastModified.HasValue)
+ {
+ options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path);
+ }
+
+ var cacheKey = path + options.DateLastModified.Value.Ticks;
+
+ options.CacheKey = cacheKey.GetMD5();
+ options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare));
+
+ return GetStaticResult(requestContext, options);
+ }
+
+ /// <summary>
+ /// Gets the file stream.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="fileShare">The file share.</param>
+ /// <returns>Stream.</returns>
+ private Stream GetFileStream(string path, FileShareMode fileShare)
+ {
+ return _fileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, fileShare);
+ }
+
+ public Task<object> GetStaticResult(IRequest requestContext,
+ Guid cacheKey,
+ DateTime? lastDateModified,
+ TimeSpan? cacheDuration,
+ string contentType,
+ Func<Task<Stream>> factoryFn,
+ IDictionary<string, string> responseHeaders = null,
+ bool isHeadRequest = false)
+ {
+ return GetStaticResult(requestContext, new StaticResultOptions
+ {
+ CacheDuration = cacheDuration,
+ CacheKey = cacheKey,
+ ContentFactory = factoryFn,
+ ContentType = contentType,
+ DateLastModified = lastDateModified,
+ IsHeadRequest = isHeadRequest,
+ ResponseHeaders = responseHeaders
+ });
+ }
+
+ public async Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options)
+ {
+ var cacheKey = options.CacheKey;
+ options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+ var contentType = options.ContentType;
+
+ if (cacheKey == Guid.Empty)
+ {
+ throw new ArgumentNullException("cacheKey");
+ }
+ if (options.ContentFactory == null)
+ {
+ throw new ArgumentNullException("factoryFn");
+ }
+
+ var key = cacheKey.ToString("N");
+
+ // See if the result is already cached in the browser
+ var result = GetCachedResult(requestContext, options.ResponseHeaders, cacheKey, key, options.DateLastModified, options.CacheDuration, contentType);
+
+ if (result != null)
+ {
+ return result;
+ }
+
+ var compress = ShouldCompressResponse(requestContext, contentType);
+ var hasHeaders = await GetStaticResult(requestContext, options, compress).ConfigureAwait(false);
+ AddResponseHeaders(hasHeaders, options.ResponseHeaders);
+
+ return hasHeaders;
+ }
+
+ /// <summary>
+ /// Shoulds the compress response.
+ /// </summary>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns>
+ private bool ShouldCompressResponse(IRequest requestContext, string contentType)
+ {
+ // It will take some work to support compression with byte range requests
+ if (!string.IsNullOrEmpty(requestContext.Headers.Get("Range")))
+ {
+ return false;
+ }
+
+ // Don't compress media
+ if (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Don't compress images
+ if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (contentType.StartsWith("font/", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ if (contentType.StartsWith("application/", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.Equals(contentType, "application/x-javascript", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (string.Equals(contentType, "application/xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ /// <summary>
+ /// The us culture
+ /// </summary>
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+ private async Task<IHasHeaders> GetStaticResult(IRequest requestContext, StaticResultOptions options, bool compress)
+ {
+ var isHeadRequest = options.IsHeadRequest;
+ var factoryFn = options.ContentFactory;
+ var contentType = options.ContentType;
+ var responseHeaders = options.ResponseHeaders;
+
+ var requestedCompressionType = GetCompressionType(requestContext);
+
+ if (!compress || string.IsNullOrEmpty(requestedCompressionType))
+ {
+ var rangeHeader = requestContext.Headers.Get("Range");
+
+ var stream = await factoryFn().ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(rangeHeader))
+ {
+ return new RangeRequestWriter(rangeHeader, stream, contentType, isHeadRequest, _logger)
+ {
+ OnComplete = options.OnComplete
+ };
+ }
+
+ responseHeaders["Content-Length"] = stream.Length.ToString(UsCulture);
+
+ if (isHeadRequest)
+ {
+ stream.Dispose();
+
+ return GetHttpResult(new byte[] { }, contentType, true);
+ }
+
+ return new StreamWriter(stream, contentType, _logger)
+ {
+ OnComplete = options.OnComplete,
+ OnError = options.OnError
+ };
+ }
+
+ string content;
+
+ using (var stream = await factoryFn().ConfigureAwait(false))
+ {
+ using (var reader = new StreamReader(stream))
+ {
+ content = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ }
+
+ var contents = Compress(content, requestedCompressionType);
+
+ responseHeaders["Content-Length"] = contents.Length.ToString(UsCulture);
+ responseHeaders["Content-Encoding"] = requestedCompressionType;
+
+ if (isHeadRequest)
+ {
+ return GetHttpResult(new byte[] { }, contentType, true);
+ }
+
+ return GetHttpResult(contents, contentType, true, responseHeaders);
+ }
+
+ private byte[] Compress(string text, string compressionType)
+ {
+ if (compressionType == "deflate")
+ return Deflate(text);
+
+ if (compressionType == "gzip")
+ return GZip(text);
+
+ throw new NotSupportedException(compressionType);
+ }
+
+ private byte[] Deflate(string text)
+ {
+ return Deflate(Encoding.UTF8.GetBytes(text));
+ }
+
+ private byte[] Deflate(byte[] bytes)
+ {
+ // In .NET FX incompat-ville, you can't access compressed bytes without closing DeflateStream
+ // Which means we must use MemoryStream since you have to use ToArray() on a closed Stream
+ using (var ms = new MemoryStream())
+ using (var zipStream = new DeflateStream(ms, CompressionMode.Compress))
+ {
+ zipStream.Write(bytes, 0, bytes.Length);
+ zipStream.Dispose();
+
+ return ms.ToArray();
+ }
+ }
+
+ private byte[] GZip(string text)
+ {
+ return GZip(Encoding.UTF8.GetBytes(text));
+ }
+
+ private byte[] GZip(byte[] buffer)
+ {
+ using (var ms = new MemoryStream())
+ using (var zipStream = new GZipStream(ms, CompressionMode.Compress))
+ {
+ zipStream.Write(buffer, 0, buffer.Length);
+ zipStream.Dispose();
+
+ return ms.ToArray();
+ }
+ }
+
+ /// <summary>
+ /// Adds the caching responseHeaders.
+ /// </summary>
+ /// <param name="responseHeaders">The responseHeaders.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ private void AddCachingHeaders(IDictionary<string, string> responseHeaders, string cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration)
+ {
+ // Don't specify both last modified and Etag, unless caching unconditionally. They are redundant
+ // https://developers.google.com/speed/docs/best-practices/caching#LeverageBrowserCaching
+ if (lastDateModified.HasValue && (string.IsNullOrEmpty(cacheKey) || cacheDuration.HasValue))
+ {
+ AddAgeHeader(responseHeaders, lastDateModified);
+ responseHeaders["Last-Modified"] = lastDateModified.Value.ToString("r");
+ }
+
+ if (cacheDuration.HasValue)
+ {
+ responseHeaders["Cache-Control"] = "public, max-age=" + Convert.ToInt32(cacheDuration.Value.TotalSeconds);
+ }
+ else if (!string.IsNullOrEmpty(cacheKey))
+ {
+ responseHeaders["Cache-Control"] = "public";
+ }
+ else
+ {
+ responseHeaders["Cache-Control"] = "no-cache, no-store, must-revalidate";
+ responseHeaders["pragma"] = "no-cache, no-store, must-revalidate";
+ }
+
+ AddExpiresHeader(responseHeaders, cacheKey, cacheDuration);
+ }
+
+ /// <summary>
+ /// Adds the expires header.
+ /// </summary>
+ /// <param name="responseHeaders">The responseHeaders.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ private void AddExpiresHeader(IDictionary<string, string> responseHeaders, string cacheKey, TimeSpan? cacheDuration)
+ {
+ if (cacheDuration.HasValue)
+ {
+ responseHeaders["Expires"] = DateTime.UtcNow.Add(cacheDuration.Value).ToString("r");
+ }
+ else if (string.IsNullOrEmpty(cacheKey))
+ {
+ responseHeaders["Expires"] = "-1";
+ }
+ }
+
+ /// <summary>
+ /// Adds the age header.
+ /// </summary>
+ /// <param name="responseHeaders">The responseHeaders.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ private void AddAgeHeader(IDictionary<string, string> responseHeaders, DateTime? lastDateModified)
+ {
+ if (lastDateModified.HasValue)
+ {
+ responseHeaders["Age"] = Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture);
+ }
+ }
+ /// <summary>
+ /// Determines whether [is not modified] [the specified cache key].
+ /// </summary>
+ /// <param name="requestContext">The request context.</param>
+ /// <param name="cacheKey">The cache key.</param>
+ /// <param name="lastDateModified">The last date modified.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ /// <returns><c>true</c> if [is not modified] [the specified cache key]; otherwise, <c>false</c>.</returns>
+ private bool IsNotModified(IRequest requestContext, Guid? cacheKey, DateTime? lastDateModified, TimeSpan? cacheDuration)
+ {
+ //var isNotModified = true;
+
+ var ifModifiedSinceHeader = requestContext.Headers.Get("If-Modified-Since");
+
+ if (!string.IsNullOrEmpty(ifModifiedSinceHeader))
+ {
+ DateTime ifModifiedSince;
+
+ if (DateTime.TryParse(ifModifiedSinceHeader, out ifModifiedSince))
+ {
+ if (IsNotModified(ifModifiedSince.ToUniversalTime(), cacheDuration, lastDateModified))
+ {
+ return true;
+ }
+ }
+ }
+
+ var ifNoneMatchHeader = requestContext.Headers.Get("If-None-Match");
+
+ // Validate If-None-Match
+ if ((cacheKey.HasValue || !string.IsNullOrEmpty(ifNoneMatchHeader)))
+ {
+ Guid ifNoneMatch;
+
+ ifNoneMatchHeader = (ifNoneMatchHeader ?? string.Empty).Trim('\"');
+
+ if (Guid.TryParse(ifNoneMatchHeader, out ifNoneMatch))
+ {
+ if (cacheKey.HasValue && cacheKey.Value == ifNoneMatch)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Determines whether [is not modified] [the specified if modified since].
+ /// </summary>
+ /// <param name="ifModifiedSince">If modified since.</param>
+ /// <param name="cacheDuration">Duration of the cache.</param>
+ /// <param name="dateModified">The date modified.</param>
+ /// <returns><c>true</c> if [is not modified] [the specified if modified since]; otherwise, <c>false</c>.</returns>
+ private bool IsNotModified(DateTime ifModifiedSince, TimeSpan? cacheDuration, DateTime? dateModified)
+ {
+ if (dateModified.HasValue)
+ {
+ var lastModified = NormalizeDateForComparison(dateModified.Value);
+ ifModifiedSince = NormalizeDateForComparison(ifModifiedSince);
+
+ return lastModified <= ifModifiedSince;
+ }
+
+ if (cacheDuration.HasValue)
+ {
+ var cacheExpirationDate = ifModifiedSince.Add(cacheDuration.Value);
+
+ if (DateTime.UtcNow < cacheExpirationDate)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+
+ /// <summary>
+ /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that
+ /// </summary>
+ /// <param name="date">The date.</param>
+ /// <returns>DateTime.</returns>
+ private DateTime NormalizeDateForComparison(DateTime date)
+ {
+ return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind);
+ }
+
+ /// <summary>
+ /// Adds the response headers.
+ /// </summary>
+ /// <param name="hasHeaders">The has options.</param>
+ /// <param name="responseHeaders">The response headers.</param>
+ private void AddResponseHeaders(IHasHeaders hasHeaders, IEnumerable<KeyValuePair<string, string>> responseHeaders)
+ {
+ foreach (var item in responseHeaders)
+ {
+ hasHeaders.Headers[item.Key] = item.Value;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/IHttpListener.cs b/Emby.Server.Implementations/HttpServer/IHttpListener.cs
new file mode 100644
index 000000000..9f96a8e49
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/IHttpListener.cs
@@ -0,0 +1,46 @@
+using MediaBrowser.Controller.Net;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public interface IHttpListener : IDisposable
+ {
+ /// <summary>
+ /// Gets or sets the error handler.
+ /// </summary>
+ /// <value>The error handler.</value>
+ Action<Exception, IRequest> ErrorHandler { get; set; }
+
+ /// <summary>
+ /// Gets or sets the request handler.
+ /// </summary>
+ /// <value>The request handler.</value>
+ Func<IHttpRequest, Uri, Task> RequestHandler { get; set; }
+
+ /// <summary>
+ /// Gets or sets the web socket handler.
+ /// </summary>
+ /// <value>The web socket handler.</value>
+ Action<WebSocketConnectEventArgs> WebSocketConnected { get; set; }
+
+ /// <summary>
+ /// Gets or sets the web socket connecting.
+ /// </summary>
+ /// <value>The web socket connecting.</value>
+ Action<WebSocketConnectingEventArgs> WebSocketConnecting { get; set; }
+
+ /// <summary>
+ /// Starts this instance.
+ /// </summary>
+ /// <param name="urlPrefixes">The URL prefixes.</param>
+ void Start(IEnumerable<string> urlPrefixes);
+
+ /// <summary>
+ /// Stops this instance.
+ /// </summary>
+ void Stop();
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/LoggerUtils.cs b/Emby.Server.Implementations/HttpServer/LoggerUtils.cs
new file mode 100644
index 000000000..8fc92a09a
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/LoggerUtils.cs
@@ -0,0 +1,43 @@
+using MediaBrowser.Model.Logging;
+using System;
+using System.Globalization;
+using SocketHttpListener.Net;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public static class LoggerUtils
+ {
+ /// <summary>
+ /// Logs the request.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="request">The request.</param>
+ public static void LogRequest(ILogger logger, HttpListenerRequest request)
+ {
+ var url = request.Url.ToString();
+
+ logger.Info("{0} {1}. UserAgent: {2}", request.IsWebSocketRequest ? "WS" : "HTTP " + request.HttpMethod, url, request.UserAgent ?? string.Empty);
+ }
+
+ public static void LogRequest(ILogger logger, string url, string method, string userAgent)
+ {
+ logger.Info("{0} {1}. UserAgent: {2}", "HTTP " + method, url, userAgent ?? string.Empty);
+ }
+
+ /// <summary>
+ /// Logs the response.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="statusCode">The status code.</param>
+ /// <param name="url">The URL.</param>
+ /// <param name="endPoint">The end point.</param>
+ /// <param name="duration">The duration.</param>
+ public static void LogResponse(ILogger logger, int statusCode, string url, string endPoint, TimeSpan duration)
+ {
+ var durationMs = duration.TotalMilliseconds;
+ var logSuffix = durationMs >= 1000 && durationMs < 60000 ? "ms (slow)" : "ms";
+
+ logger.Info("HTTP Response {0} to {1}. Time: {2}{3}. {4}", statusCode, endPoint, Convert.ToInt32(durationMs).ToString(CultureInfo.InvariantCulture), logSuffix, url);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs b/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs
new file mode 100644
index 000000000..e88994bec
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs
@@ -0,0 +1,224 @@
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public class RangeRequestWriter : IAsyncStreamWriter, IHttpResult
+ {
+ /// <summary>
+ /// Gets or sets the source stream.
+ /// </summary>
+ /// <value>The source stream.</value>
+ private Stream SourceStream { get; set; }
+ private string RangeHeader { get; set; }
+ private bool IsHeadRequest { get; set; }
+
+ private long RangeStart { get; set; }
+ private long RangeEnd { get; set; }
+ private long RangeLength { get; set; }
+ private long TotalContentLength { get; set; }
+
+ public Action OnComplete { get; set; }
+ private readonly ILogger _logger;
+
+ private const int BufferSize = 81920;
+
+ /// <summary>
+ /// The _options
+ /// </summary>
+ private readonly Dictionary<string, string> _options = new Dictionary<string, string>();
+
+ /// <summary>
+ /// The us culture
+ /// </summary>
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+ public List<Cookie> Cookies { get; private set; }
+
+ /// <summary>
+ /// Additional HTTP Headers
+ /// </summary>
+ /// <value>The headers.</value>
+ public IDictionary<string, string> Headers
+ {
+ get { return _options; }
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StreamWriter" /> class.
+ /// </summary>
+ /// <param name="rangeHeader">The range header.</param>
+ /// <param name="source">The source.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param>
+ public RangeRequestWriter(string rangeHeader, Stream source, string contentType, bool isHeadRequest, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(contentType))
+ {
+ throw new ArgumentNullException("contentType");
+ }
+
+ RangeHeader = rangeHeader;
+ SourceStream = source;
+ IsHeadRequest = isHeadRequest;
+ this._logger = logger;
+
+ ContentType = contentType;
+ Headers["Content-Type"] = contentType;
+ Headers["Accept-Ranges"] = "bytes";
+ StatusCode = HttpStatusCode.PartialContent;
+
+ Cookies = new List<Cookie>();
+ SetRangeValues();
+ }
+
+ /// <summary>
+ /// Sets the range values.
+ /// </summary>
+ private void SetRangeValues()
+ {
+ var requestedRange = RequestedRanges[0];
+
+ TotalContentLength = SourceStream.Length;
+
+ // If the requested range is "0-", we can optimize by just doing a stream copy
+ if (!requestedRange.Value.HasValue)
+ {
+ RangeEnd = TotalContentLength - 1;
+ }
+ else
+ {
+ RangeEnd = requestedRange.Value.Value;
+ }
+
+ RangeStart = requestedRange.Key;
+ RangeLength = 1 + RangeEnd - RangeStart;
+
+ // Content-Length is the length of what we're serving, not the original content
+ Headers["Content-Length"] = RangeLength.ToString(UsCulture);
+ Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", RangeStart, RangeEnd, TotalContentLength);
+
+ if (RangeStart > 0)
+ {
+ SourceStream.Position = RangeStart;
+ }
+ }
+
+ /// <summary>
+ /// The _requested ranges
+ /// </summary>
+ private List<KeyValuePair<long, long?>> _requestedRanges;
+ /// <summary>
+ /// Gets the requested ranges.
+ /// </summary>
+ /// <value>The requested ranges.</value>
+ protected List<KeyValuePair<long, long?>> RequestedRanges
+ {
+ get
+ {
+ if (_requestedRanges == null)
+ {
+ _requestedRanges = new List<KeyValuePair<long, long?>>();
+
+ // Example: bytes=0-,32-63
+ var ranges = RangeHeader.Split('=')[1].Split(',');
+
+ foreach (var range in ranges)
+ {
+ var vals = range.Split('-');
+
+ long start = 0;
+ long? end = null;
+
+ if (!string.IsNullOrEmpty(vals[0]))
+ {
+ start = long.Parse(vals[0], UsCulture);
+ }
+ if (!string.IsNullOrEmpty(vals[1]))
+ {
+ end = long.Parse(vals[1], UsCulture);
+ }
+
+ _requestedRanges.Add(new KeyValuePair<long, long?>(start, end));
+ }
+ }
+
+ return _requestedRanges;
+ }
+ }
+
+ public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Headers only
+ if (IsHeadRequest)
+ {
+ return;
+ }
+
+ using (var source = SourceStream)
+ {
+ // If the requested range is "0-", we can optimize by just doing a stream copy
+ if (RangeEnd >= TotalContentLength - 1)
+ {
+ await source.CopyToAsync(responseStream, BufferSize).ConfigureAwait(false);
+ }
+ else
+ {
+ await CopyToInternalAsync(source, responseStream, RangeLength).ConfigureAwait(false);
+ }
+ }
+ }
+ finally
+ {
+ if (OnComplete != null)
+ {
+ OnComplete();
+ }
+ }
+ }
+
+ private async Task CopyToInternalAsync(Stream source, Stream destination, long copyLength)
+ {
+ var array = new byte[BufferSize];
+ int count;
+ while ((count = await source.ReadAsync(array, 0, array.Length).ConfigureAwait(false)) != 0)
+ {
+ var bytesToCopy = Math.Min(count, copyLength);
+
+ await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToCopy)).ConfigureAwait(false);
+
+ copyLength -= bytesToCopy;
+
+ if (copyLength <= 0)
+ {
+ break;
+ }
+ }
+ }
+
+ public string ContentType { get; set; }
+
+ public IRequest RequestContext { get; set; }
+
+ public object Response { get; set; }
+
+ public int Status { get; set; }
+
+ public HttpStatusCode StatusCode
+ {
+ get { return (HttpStatusCode)Status; }
+ set { Status = (int)value; }
+ }
+
+ public string StatusDescription { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/ResponseFilter.cs b/Emby.Server.Implementations/HttpServer/ResponseFilter.cs
new file mode 100644
index 000000000..6d9d7d921
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/ResponseFilter.cs
@@ -0,0 +1,129 @@
+using MediaBrowser.Model.Logging;
+using System;
+using System.Globalization;
+using System.Text;
+using Emby.Server.Implementations.HttpServer.SocketSharp;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public class ResponseFilter
+ {
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+ private readonly ILogger _logger;
+
+ public ResponseFilter(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Filters the response.
+ /// </summary>
+ /// <param name="req">The req.</param>
+ /// <param name="res">The res.</param>
+ /// <param name="dto">The dto.</param>
+ public void FilterResponse(IRequest req, IResponse res, object dto)
+ {
+ // Try to prevent compatibility view
+ res.AddHeader("X-UA-Compatible", "IE=Edge");
+ res.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization");
+ res.AddHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
+ res.AddHeader("Access-Control-Allow-Origin", "*");
+
+ var exception = dto as Exception;
+
+ if (exception != null)
+ {
+ _logger.ErrorException("Error processing request for {0}", exception, req.RawUrl);
+
+ if (!string.IsNullOrEmpty(exception.Message))
+ {
+ var error = exception.Message.Replace(Environment.NewLine, " ");
+ error = RemoveControlCharacters(error);
+
+ res.AddHeader("X-Application-Error-Code", error);
+ }
+ }
+
+ var vary = "Accept-Encoding";
+
+ var hasHeaders = dto as IHasHeaders;
+ var sharpResponse = res as WebSocketSharpResponse;
+
+ if (hasHeaders != null)
+ {
+ if (!hasHeaders.Headers.ContainsKey("Server"))
+ {
+ hasHeaders.Headers["Server"] = "Mono-HTTPAPI/1.1, UPnP/1.0 DLNADOC/1.50";
+ //hasHeaders.Headers["Server"] = "Mono-HTTPAPI/1.1";
+ }
+
+ // Content length has to be explicitly set on on HttpListenerResponse or it won't be happy
+ string contentLength;
+
+ if (hasHeaders.Headers.TryGetValue("Content-Length", out contentLength) && !string.IsNullOrEmpty(contentLength))
+ {
+ var length = long.Parse(contentLength, UsCulture);
+
+ if (length > 0)
+ {
+ res.SetContentLength(length);
+
+ //var listenerResponse = res.OriginalResponse as HttpListenerResponse;
+
+ //if (listenerResponse != null)
+ //{
+ // // Disable chunked encoding. Technically this is only needed when using Content-Range, but
+ // // anytime we know the content length there's no need for it
+ // listenerResponse.SendChunked = false;
+ // return;
+ //}
+
+ if (sharpResponse != null)
+ {
+ sharpResponse.SendChunked = false;
+ }
+ }
+ }
+
+ string hasHeadersVary;
+ if (hasHeaders.Headers.TryGetValue("Vary", out hasHeadersVary))
+ {
+ vary = hasHeadersVary;
+ }
+
+ hasHeaders.Headers["Vary"] = vary;
+ }
+
+ //res.KeepAlive = false;
+
+ // Per Google PageSpeed
+ // This instructs the proxies to cache two versions of the resource: one compressed, and one uncompressed.
+ // The correct version of the resource is delivered based on the client request header.
+ // This is a good choice for applications that are singly homed and depend on public proxies for user locality.
+ res.AddHeader("Vary", vary);
+ }
+
+ /// <summary>
+ /// Removes the control characters.
+ /// </summary>
+ /// <param name="inString">The in string.</param>
+ /// <returns>System.String.</returns>
+ public static string RemoveControlCharacters(string inString)
+ {
+ if (inString == null) return null;
+
+ var newString = new StringBuilder();
+
+ foreach (var ch in inString)
+ {
+ if (!char.IsControl(ch))
+ {
+ newString.Append(ch);
+ }
+ }
+ return newString.ToString();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
new file mode 100644
index 000000000..4d00c9b19
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
@@ -0,0 +1,246 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Emby.Server.Implementations.HttpServer.Security
+{
+ public class AuthService : IAuthService
+ {
+ private readonly IServerConfigurationManager _config;
+
+ public AuthService(IUserManager userManager, IAuthorizationContext authorizationContext, IServerConfigurationManager config, IConnectManager connectManager, ISessionManager sessionManager, IDeviceManager deviceManager)
+ {
+ AuthorizationContext = authorizationContext;
+ _config = config;
+ DeviceManager = deviceManager;
+ SessionManager = sessionManager;
+ ConnectManager = connectManager;
+ UserManager = userManager;
+ }
+
+ public IUserManager UserManager { get; private set; }
+ public IAuthorizationContext AuthorizationContext { get; private set; }
+ public IConnectManager ConnectManager { get; private set; }
+ public ISessionManager SessionManager { get; private set; }
+ public IDeviceManager DeviceManager { get; private set; }
+
+ /// <summary>
+ /// Redirect the client to a specific URL if authentication failed.
+ /// If this property is null, simply `401 Unauthorized` is returned.
+ /// </summary>
+ public string HtmlRedirect { get; set; }
+
+ public void Authenticate(IServiceRequest request,
+ IAuthenticationAttributes authAttribtues)
+ {
+ ValidateUser(request, authAttribtues);
+ }
+
+ private void ValidateUser(IServiceRequest request,
+ IAuthenticationAttributes authAttribtues)
+ {
+ // This code is executed before the service
+ var auth = AuthorizationContext.GetAuthorizationInfo(request);
+
+ if (!IsExemptFromAuthenticationToken(auth, authAttribtues))
+ {
+ var valid = IsValidConnectKey(auth.Token);
+
+ if (!valid)
+ {
+ ValidateSecurityToken(request, auth.Token);
+ }
+ }
+
+ var user = string.IsNullOrWhiteSpace(auth.UserId)
+ ? null
+ : UserManager.GetUserById(auth.UserId);
+
+ if (user == null & !string.IsNullOrWhiteSpace(auth.UserId))
+ {
+ throw new SecurityException("User with Id " + auth.UserId + " not found");
+ }
+
+ if (user != null)
+ {
+ ValidateUserAccess(user, request, authAttribtues, auth);
+ }
+
+ var info = GetTokenInfo(request);
+
+ if (!IsExemptFromRoles(auth, authAttribtues, info))
+ {
+ var roles = authAttribtues.GetRoles().ToList();
+
+ ValidateRoles(roles, user);
+ }
+
+ if (!string.IsNullOrWhiteSpace(auth.DeviceId) &&
+ !string.IsNullOrWhiteSpace(auth.Client) &&
+ !string.IsNullOrWhiteSpace(auth.Device))
+ {
+ SessionManager.LogSessionActivity(auth.Client,
+ auth.Version,
+ auth.DeviceId,
+ auth.Device,
+ request.RemoteIp,
+ user);
+ }
+ }
+
+ private void ValidateUserAccess(User user, IServiceRequest request,
+ IAuthenticationAttributes authAttribtues,
+ AuthorizationInfo auth)
+ {
+ if (user.Policy.IsDisabled)
+ {
+ throw new SecurityException("User account has been disabled.")
+ {
+ SecurityExceptionType = SecurityExceptionType.Unauthenticated
+ };
+ }
+
+ if (!user.Policy.IsAdministrator &&
+ !authAttribtues.EscapeParentalControl &&
+ !user.IsParentalScheduleAllowed())
+ {
+ request.AddResponseHeader("X-Application-Error-Code", "ParentalControl");
+
+ throw new SecurityException("This user account is not allowed access at this time.")
+ {
+ SecurityExceptionType = SecurityExceptionType.ParentalControl
+ };
+ }
+
+ if (!string.IsNullOrWhiteSpace(auth.DeviceId))
+ {
+ if (!DeviceManager.CanAccessDevice(user.Id.ToString("N"), auth.DeviceId))
+ {
+ throw new SecurityException("User is not allowed access from this device.")
+ {
+ SecurityExceptionType = SecurityExceptionType.ParentalControl
+ };
+ }
+ }
+ }
+
+ private bool IsExemptFromAuthenticationToken(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues)
+ {
+ if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool IsExemptFromRoles(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues, AuthenticationInfo tokenInfo)
+ {
+ if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard)
+ {
+ return true;
+ }
+
+ if (string.IsNullOrWhiteSpace(auth.Token))
+ {
+ return true;
+ }
+
+ if (tokenInfo != null && string.IsNullOrWhiteSpace(tokenInfo.UserId))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ValidateRoles(List<string> roles, User user)
+ {
+ if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase))
+ {
+ if (user == null || !user.Policy.IsAdministrator)
+ {
+ throw new SecurityException("User does not have admin access.")
+ {
+ SecurityExceptionType = SecurityExceptionType.Unauthenticated
+ };
+ }
+ }
+ if (roles.Contains("delete", StringComparer.OrdinalIgnoreCase))
+ {
+ if (user == null || !user.Policy.EnableContentDeletion)
+ {
+ throw new SecurityException("User does not have delete access.")
+ {
+ SecurityExceptionType = SecurityExceptionType.Unauthenticated
+ };
+ }
+ }
+ if (roles.Contains("download", StringComparer.OrdinalIgnoreCase))
+ {
+ if (user == null || !user.Policy.EnableContentDownloading)
+ {
+ throw new SecurityException("User does not have download access.")
+ {
+ SecurityExceptionType = SecurityExceptionType.Unauthenticated
+ };
+ }
+ }
+ }
+
+ private AuthenticationInfo GetTokenInfo(IServiceRequest request)
+ {
+ object info;
+ request.Items.TryGetValue("OriginalAuthenticationInfo", out info);
+ return info as AuthenticationInfo;
+ }
+
+ private bool IsValidConnectKey(string token)
+ {
+ if (string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ return ConnectManager.IsAuthorizationTokenValid(token);
+ }
+
+ private void ValidateSecurityToken(IServiceRequest request, string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new SecurityException("Access token is required.");
+ }
+
+ var info = GetTokenInfo(request);
+
+ if (info == null)
+ {
+ throw new SecurityException("Access token is invalid or expired.");
+ }
+
+ if (!info.IsActive)
+ {
+ throw new SecurityException("Access token has expired.");
+ }
+
+ //if (!string.IsNullOrWhiteSpace(info.UserId))
+ //{
+ // var user = _userManager.GetUserById(info.UserId);
+
+ // if (user == null || user.Configuration.IsDisabled)
+ // {
+ // throw new SecurityException("User account has been disabled.");
+ // }
+ //}
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
new file mode 100644
index 000000000..ec3dfeb60
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
@@ -0,0 +1,195 @@
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Security;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer.Security
+{
+ public class AuthorizationContext : IAuthorizationContext
+ {
+ private readonly IAuthenticationRepository _authRepo;
+ private readonly IConnectManager _connectManager;
+
+ public AuthorizationContext(IAuthenticationRepository authRepo, IConnectManager connectManager)
+ {
+ _authRepo = authRepo;
+ _connectManager = connectManager;
+ }
+
+ public AuthorizationInfo GetAuthorizationInfo(object requestContext)
+ {
+ var req = new ServiceRequest((IRequest)requestContext);
+ return GetAuthorizationInfo(req);
+ }
+
+ public AuthorizationInfo GetAuthorizationInfo(IServiceRequest requestContext)
+ {
+ object cached;
+ if (requestContext.Items.TryGetValue("AuthorizationInfo", out cached))
+ {
+ return (AuthorizationInfo)cached;
+ }
+
+ return GetAuthorization(requestContext);
+ }
+
+ /// <summary>
+ /// Gets the authorization.
+ /// </summary>
+ /// <param name="httpReq">The HTTP req.</param>
+ /// <returns>Dictionary{System.StringSystem.String}.</returns>
+ private AuthorizationInfo GetAuthorization(IServiceRequest httpReq)
+ {
+ var auth = GetAuthorizationDictionary(httpReq);
+
+ string deviceId = null;
+ string device = null;
+ string client = null;
+ string version = null;
+
+ if (auth != null)
+ {
+ auth.TryGetValue("DeviceId", out deviceId);
+ auth.TryGetValue("Device", out device);
+ auth.TryGetValue("Client", out client);
+ auth.TryGetValue("Version", out version);
+ }
+
+ var token = httpReq.Headers["X-Emby-Token"];
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ token = httpReq.Headers["X-MediaBrowser-Token"];
+ }
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ token = httpReq.QueryString["api_key"];
+ }
+
+ var info = new AuthorizationInfo
+ {
+ Client = client,
+ Device = device,
+ DeviceId = deviceId,
+ Version = version,
+ Token = token
+ };
+
+ if (!string.IsNullOrWhiteSpace(token))
+ {
+ var result = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ AccessToken = token
+ });
+
+ var tokenInfo = result.Items.FirstOrDefault();
+
+ if (tokenInfo != null)
+ {
+ info.UserId = tokenInfo.UserId;
+
+ // TODO: Remove these checks for IsNullOrWhiteSpace
+ if (string.IsNullOrWhiteSpace(info.Client))
+ {
+ info.Client = tokenInfo.AppName;
+ }
+ if (string.IsNullOrWhiteSpace(info.Device))
+ {
+ info.Device = tokenInfo.DeviceName;
+ }
+ if (string.IsNullOrWhiteSpace(info.DeviceId))
+ {
+ info.DeviceId = tokenInfo.DeviceId;
+ }
+ if (string.IsNullOrWhiteSpace(info.Version))
+ {
+ info.Version = tokenInfo.AppVersion;
+ }
+ }
+ else
+ {
+ var user = _connectManager.GetUserFromExchangeToken(token);
+ if (user != null)
+ {
+ info.UserId = user.Id.ToString("N");
+ }
+ }
+ httpReq.Items["OriginalAuthenticationInfo"] = tokenInfo;
+ }
+
+ httpReq.Items["AuthorizationInfo"] = info;
+
+ return info;
+ }
+
+ /// <summary>
+ /// Gets the auth.
+ /// </summary>
+ /// <param name="httpReq">The HTTP req.</param>
+ /// <returns>Dictionary{System.StringSystem.String}.</returns>
+ private Dictionary<string, string> GetAuthorizationDictionary(IServiceRequest httpReq)
+ {
+ var auth = httpReq.Headers["X-Emby-Authorization"];
+
+ if (string.IsNullOrWhiteSpace(auth))
+ {
+ auth = httpReq.Headers["Authorization"];
+ }
+
+ return GetAuthorization(auth);
+ }
+
+ /// <summary>
+ /// Gets the authorization.
+ /// </summary>
+ /// <param name="authorizationHeader">The authorization header.</param>
+ /// <returns>Dictionary{System.StringSystem.String}.</returns>
+ private Dictionary<string, string> GetAuthorization(string authorizationHeader)
+ {
+ if (authorizationHeader == null) return null;
+
+ var parts = authorizationHeader.Split(new[] { ' ' }, 2);
+
+ // There should be at least to parts
+ if (parts.Length != 2) return null;
+
+ // It has to be a digest request
+ if (!string.Equals(parts[0], "MediaBrowser", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // Remove uptil the first space
+ authorizationHeader = parts[1];
+ parts = authorizationHeader.Split(',');
+
+ var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var item in parts)
+ {
+ var param = item.Trim().Split(new[] { '=' }, 2);
+
+ if (param.Length == 2)
+ {
+ var value = NormalizeValue (param[1].Trim(new[] { '"' }));
+ result.Add(param[0], value);
+ }
+ }
+
+ return result;
+ }
+
+ private string NormalizeValue(string value)
+ {
+ if (string.IsNullOrWhiteSpace (value))
+ {
+ return value;
+ }
+
+ return System.Net.WebUtility.HtmlEncode(value);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
new file mode 100644
index 000000000..33dd4e2d7
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
@@ -0,0 +1,67 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer.Security
+{
+ public class SessionContext : ISessionContext
+ {
+ private readonly IUserManager _userManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly IAuthorizationContext _authContext;
+
+ public SessionContext(IUserManager userManager, IAuthorizationContext authContext, ISessionManager sessionManager)
+ {
+ _userManager = userManager;
+ _authContext = authContext;
+ _sessionManager = sessionManager;
+ }
+
+ public Task<SessionInfo> GetSession(IServiceRequest requestContext)
+ {
+ var authorization = _authContext.GetAuthorizationInfo(requestContext);
+
+ //if (!string.IsNullOrWhiteSpace(authorization.Token))
+ //{
+ // var auth = GetTokenInfo(requestContext);
+ // if (auth != null)
+ // {
+ // return _sessionManager.GetSessionByAuthenticationToken(auth, authorization.DeviceId, requestContext.RemoteIp, authorization.Version);
+ // }
+ //}
+
+ var user = string.IsNullOrWhiteSpace(authorization.UserId) ? null : _userManager.GetUserById(authorization.UserId);
+ return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.RemoteIp, user);
+ }
+
+ private AuthenticationInfo GetTokenInfo(IServiceRequest request)
+ {
+ object info;
+ request.Items.TryGetValue("OriginalAuthenticationInfo", out info);
+ return info as AuthenticationInfo;
+ }
+
+ public Task<SessionInfo> GetSession(object requestContext)
+ {
+ var req = new ServiceRequest((IRequest)requestContext);
+ return GetSession(req);
+ }
+
+ public async Task<User> GetUser(IServiceRequest requestContext)
+ {
+ var session = await GetSession(requestContext).ConfigureAwait(false);
+
+ return session == null || !session.UserId.HasValue ? null : _userManager.GetUserById(session.UserId.Value);
+ }
+
+ public Task<User> GetUser(object requestContext)
+ {
+ var req = new ServiceRequest((IRequest)requestContext);
+ return GetUser(req);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/Extensions.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/Extensions.cs
new file mode 100644
index 000000000..07a338f19
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/Extensions.cs
@@ -0,0 +1,12 @@
+using SocketHttpListener.Net;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public static class Extensions
+ {
+ public static string GetOperationName(this HttpListenerRequest request)
+ {
+ return request.Url.Segments[request.Url.Segments.Length - 1];
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/HttpUtility.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/HttpUtility.cs
new file mode 100644
index 000000000..4fbe0ed94
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/HttpUtility.cs
@@ -0,0 +1,922 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public static class MyHttpUtility
+ {
+ // Must be sorted
+ static readonly long[] entities = new long[] {
+ (long)'A' << 56 | (long)'E' << 48 | (long)'l' << 40 | (long)'i' << 32 | (long)'g' << 24,
+ (long)'A' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'A' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'A' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'A' << 56 | (long)'l' << 48 | (long)'p' << 40 | (long)'h' << 32 | (long)'a' << 24,
+ (long)'A' << 56 | (long)'r' << 48 | (long)'i' << 40 | (long)'n' << 32 | (long)'g' << 24,
+ (long)'A' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'A' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'B' << 56 | (long)'e' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'C' << 56 | (long)'c' << 48 | (long)'e' << 40 | (long)'d' << 32 | (long)'i' << 24 | (long)'l' << 16,
+ (long)'C' << 56 | (long)'h' << 48 | (long)'i' << 40,
+ (long)'D' << 56 | (long)'a' << 48 | (long)'g' << 40 | (long)'g' << 32 | (long)'e' << 24 | (long)'r' << 16,
+ (long)'D' << 56 | (long)'e' << 48 | (long)'l' << 40 | (long)'t' << 32 | (long)'a' << 24,
+ (long)'E' << 56 | (long)'T' << 48 | (long)'H' << 40,
+ (long)'E' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'E' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'E' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'E' << 56 | (long)'p' << 48 | (long)'s' << 40 | (long)'i' << 32 | (long)'l' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'E' << 56 | (long)'t' << 48 | (long)'a' << 40,
+ (long)'E' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'G' << 56 | (long)'a' << 48 | (long)'m' << 40 | (long)'m' << 32 | (long)'a' << 24,
+ (long)'I' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'I' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'I' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'I' << 56 | (long)'o' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'I' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'K' << 56 | (long)'a' << 48 | (long)'p' << 40 | (long)'p' << 32 | (long)'a' << 24,
+ (long)'L' << 56 | (long)'a' << 48 | (long)'m' << 40 | (long)'b' << 32 | (long)'d' << 24 | (long)'a' << 16,
+ (long)'M' << 56 | (long)'u' << 48,
+ (long)'N' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'N' << 56 | (long)'u' << 48,
+ (long)'O' << 56 | (long)'E' << 48 | (long)'l' << 40 | (long)'i' << 32 | (long)'g' << 24,
+ (long)'O' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'O' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'O' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'O' << 56 | (long)'m' << 48 | (long)'e' << 40 | (long)'g' << 32 | (long)'a' << 24,
+ (long)'O' << 56 | (long)'m' << 48 | (long)'i' << 40 | (long)'c' << 32 | (long)'r' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'O' << 56 | (long)'s' << 48 | (long)'l' << 40 | (long)'a' << 32 | (long)'s' << 24 | (long)'h' << 16,
+ (long)'O' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'O' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'P' << 56 | (long)'h' << 48 | (long)'i' << 40,
+ (long)'P' << 56 | (long)'i' << 48,
+ (long)'P' << 56 | (long)'r' << 48 | (long)'i' << 40 | (long)'m' << 32 | (long)'e' << 24,
+ (long)'P' << 56 | (long)'s' << 48 | (long)'i' << 40,
+ (long)'R' << 56 | (long)'h' << 48 | (long)'o' << 40,
+ (long)'S' << 56 | (long)'c' << 48 | (long)'a' << 40 | (long)'r' << 32 | (long)'o' << 24 | (long)'n' << 16,
+ (long)'S' << 56 | (long)'i' << 48 | (long)'g' << 40 | (long)'m' << 32 | (long)'a' << 24,
+ (long)'T' << 56 | (long)'H' << 48 | (long)'O' << 40 | (long)'R' << 32 | (long)'N' << 24,
+ (long)'T' << 56 | (long)'a' << 48 | (long)'u' << 40,
+ (long)'T' << 56 | (long)'h' << 48 | (long)'e' << 40 | (long)'t' << 32 | (long)'a' << 24,
+ (long)'U' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'U' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'U' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'U' << 56 | (long)'p' << 48 | (long)'s' << 40 | (long)'i' << 32 | (long)'l' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'U' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'X' << 56 | (long)'i' << 48,
+ (long)'Y' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'Y' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'Z' << 56 | (long)'e' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'a' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'a' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'a' << 56 | (long)'c' << 48 | (long)'u' << 40 | (long)'t' << 32 | (long)'e' << 24,
+ (long)'a' << 56 | (long)'e' << 48 | (long)'l' << 40 | (long)'i' << 32 | (long)'g' << 24,
+ (long)'a' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'a' << 56 | (long)'l' << 48 | (long)'e' << 40 | (long)'f' << 32 | (long)'s' << 24 | (long)'y' << 16 | (long)'m' << 8,
+ (long)'a' << 56 | (long)'l' << 48 | (long)'p' << 40 | (long)'h' << 32 | (long)'a' << 24,
+ (long)'a' << 56 | (long)'m' << 48 | (long)'p' << 40,
+ (long)'a' << 56 | (long)'n' << 48 | (long)'d' << 40,
+ (long)'a' << 56 | (long)'n' << 48 | (long)'g' << 40,
+ (long)'a' << 56 | (long)'p' << 48 | (long)'o' << 40 | (long)'s' << 32,
+ (long)'a' << 56 | (long)'r' << 48 | (long)'i' << 40 | (long)'n' << 32 | (long)'g' << 24,
+ (long)'a' << 56 | (long)'s' << 48 | (long)'y' << 40 | (long)'m' << 32 | (long)'p' << 24,
+ (long)'a' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'a' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'b' << 56 | (long)'d' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'b' << 56 | (long)'e' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'b' << 56 | (long)'r' << 48 | (long)'v' << 40 | (long)'b' << 32 | (long)'a' << 24 | (long)'r' << 16,
+ (long)'b' << 56 | (long)'u' << 48 | (long)'l' << 40 | (long)'l' << 32,
+ (long)'c' << 56 | (long)'a' << 48 | (long)'p' << 40,
+ (long)'c' << 56 | (long)'c' << 48 | (long)'e' << 40 | (long)'d' << 32 | (long)'i' << 24 | (long)'l' << 16,
+ (long)'c' << 56 | (long)'e' << 48 | (long)'d' << 40 | (long)'i' << 32 | (long)'l' << 24,
+ (long)'c' << 56 | (long)'e' << 48 | (long)'n' << 40 | (long)'t' << 32,
+ (long)'c' << 56 | (long)'h' << 48 | (long)'i' << 40,
+ (long)'c' << 56 | (long)'i' << 48 | (long)'r' << 40 | (long)'c' << 32,
+ (long)'c' << 56 | (long)'l' << 48 | (long)'u' << 40 | (long)'b' << 32 | (long)'s' << 24,
+ (long)'c' << 56 | (long)'o' << 48 | (long)'n' << 40 | (long)'g' << 32,
+ (long)'c' << 56 | (long)'o' << 48 | (long)'p' << 40 | (long)'y' << 32,
+ (long)'c' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'r' << 32 | (long)'r' << 24,
+ (long)'c' << 56 | (long)'u' << 48 | (long)'p' << 40,
+ (long)'c' << 56 | (long)'u' << 48 | (long)'r' << 40 | (long)'r' << 32 | (long)'e' << 24 | (long)'n' << 16,
+ (long)'d' << 56 | (long)'A' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'d' << 56 | (long)'a' << 48 | (long)'g' << 40 | (long)'g' << 32 | (long)'e' << 24 | (long)'r' << 16,
+ (long)'d' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'d' << 56 | (long)'e' << 48 | (long)'g' << 40,
+ (long)'d' << 56 | (long)'e' << 48 | (long)'l' << 40 | (long)'t' << 32 | (long)'a' << 24,
+ (long)'d' << 56 | (long)'i' << 48 | (long)'a' << 40 | (long)'m' << 32 | (long)'s' << 24,
+ (long)'d' << 56 | (long)'i' << 48 | (long)'v' << 40 | (long)'i' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'e' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'e' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'e' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'e' << 56 | (long)'m' << 48 | (long)'p' << 40 | (long)'t' << 32 | (long)'y' << 24,
+ (long)'e' << 56 | (long)'m' << 48 | (long)'s' << 40 | (long)'p' << 32,
+ (long)'e' << 56 | (long)'n' << 48 | (long)'s' << 40 | (long)'p' << 32,
+ (long)'e' << 56 | (long)'p' << 48 | (long)'s' << 40 | (long)'i' << 32 | (long)'l' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'e' << 56 | (long)'q' << 48 | (long)'u' << 40 | (long)'i' << 32 | (long)'v' << 24,
+ (long)'e' << 56 | (long)'t' << 48 | (long)'a' << 40,
+ (long)'e' << 56 | (long)'t' << 48 | (long)'h' << 40,
+ (long)'e' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'e' << 56 | (long)'u' << 48 | (long)'r' << 40 | (long)'o' << 32,
+ (long)'e' << 56 | (long)'x' << 48 | (long)'i' << 40 | (long)'s' << 32 | (long)'t' << 24,
+ (long)'f' << 56 | (long)'n' << 48 | (long)'o' << 40 | (long)'f' << 32,
+ (long)'f' << 56 | (long)'o' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'l' << 24 | (long)'l' << 16,
+ (long)'f' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'c' << 32 | (long)'1' << 24 | (long)'2' << 16,
+ (long)'f' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'c' << 32 | (long)'1' << 24 | (long)'4' << 16,
+ (long)'f' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'c' << 32 | (long)'3' << 24 | (long)'4' << 16,
+ (long)'f' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'s' << 32 | (long)'l' << 24,
+ (long)'g' << 56 | (long)'a' << 48 | (long)'m' << 40 | (long)'m' << 32 | (long)'a' << 24,
+ (long)'g' << 56 | (long)'e' << 48,
+ (long)'g' << 56 | (long)'t' << 48,
+ (long)'h' << 56 | (long)'A' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'h' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'h' << 56 | (long)'e' << 48 | (long)'a' << 40 | (long)'r' << 32 | (long)'t' << 24 | (long)'s' << 16,
+ (long)'h' << 56 | (long)'e' << 48 | (long)'l' << 40 | (long)'l' << 32 | (long)'i' << 24 | (long)'p' << 16,
+ (long)'i' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'i' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'i' << 56 | (long)'e' << 48 | (long)'x' << 40 | (long)'c' << 32 | (long)'l' << 24,
+ (long)'i' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'i' << 56 | (long)'m' << 48 | (long)'a' << 40 | (long)'g' << 32 | (long)'e' << 24,
+ (long)'i' << 56 | (long)'n' << 48 | (long)'f' << 40 | (long)'i' << 32 | (long)'n' << 24,
+ (long)'i' << 56 | (long)'n' << 48 | (long)'t' << 40,
+ (long)'i' << 56 | (long)'o' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'i' << 56 | (long)'q' << 48 | (long)'u' << 40 | (long)'e' << 32 | (long)'s' << 24 | (long)'t' << 16,
+ (long)'i' << 56 | (long)'s' << 48 | (long)'i' << 40 | (long)'n' << 32,
+ (long)'i' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'k' << 56 | (long)'a' << 48 | (long)'p' << 40 | (long)'p' << 32 | (long)'a' << 24,
+ (long)'l' << 56 | (long)'A' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'l' << 56 | (long)'a' << 48 | (long)'m' << 40 | (long)'b' << 32 | (long)'d' << 24 | (long)'a' << 16,
+ (long)'l' << 56 | (long)'a' << 48 | (long)'n' << 40 | (long)'g' << 32,
+ (long)'l' << 56 | (long)'a' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'l' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'l' << 56 | (long)'c' << 48 | (long)'e' << 40 | (long)'i' << 32 | (long)'l' << 24,
+ (long)'l' << 56 | (long)'d' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'l' << 56 | (long)'e' << 48,
+ (long)'l' << 56 | (long)'f' << 48 | (long)'l' << 40 | (long)'o' << 32 | (long)'o' << 24 | (long)'r' << 16,
+ (long)'l' << 56 | (long)'o' << 48 | (long)'w' << 40 | (long)'a' << 32 | (long)'s' << 24 | (long)'t' << 16,
+ (long)'l' << 56 | (long)'o' << 48 | (long)'z' << 40,
+ (long)'l' << 56 | (long)'r' << 48 | (long)'m' << 40,
+ (long)'l' << 56 | (long)'s' << 48 | (long)'a' << 40 | (long)'q' << 32 | (long)'u' << 24 | (long)'o' << 16,
+ (long)'l' << 56 | (long)'s' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'l' << 56 | (long)'t' << 48,
+ (long)'m' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'r' << 32,
+ (long)'m' << 56 | (long)'d' << 48 | (long)'a' << 40 | (long)'s' << 32 | (long)'h' << 24,
+ (long)'m' << 56 | (long)'i' << 48 | (long)'c' << 40 | (long)'r' << 32 | (long)'o' << 24,
+ (long)'m' << 56 | (long)'i' << 48 | (long)'d' << 40 | (long)'d' << 32 | (long)'o' << 24 | (long)'t' << 16,
+ (long)'m' << 56 | (long)'i' << 48 | (long)'n' << 40 | (long)'u' << 32 | (long)'s' << 24,
+ (long)'m' << 56 | (long)'u' << 48,
+ (long)'n' << 56 | (long)'a' << 48 | (long)'b' << 40 | (long)'l' << 32 | (long)'a' << 24,
+ (long)'n' << 56 | (long)'b' << 48 | (long)'s' << 40 | (long)'p' << 32,
+ (long)'n' << 56 | (long)'d' << 48 | (long)'a' << 40 | (long)'s' << 32 | (long)'h' << 24,
+ (long)'n' << 56 | (long)'e' << 48,
+ (long)'n' << 56 | (long)'i' << 48,
+ (long)'n' << 56 | (long)'o' << 48 | (long)'t' << 40,
+ (long)'n' << 56 | (long)'o' << 48 | (long)'t' << 40 | (long)'i' << 32 | (long)'n' << 24,
+ (long)'n' << 56 | (long)'s' << 48 | (long)'u' << 40 | (long)'b' << 32,
+ (long)'n' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'n' << 56 | (long)'u' << 48,
+ (long)'o' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'o' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'o' << 56 | (long)'e' << 48 | (long)'l' << 40 | (long)'i' << 32 | (long)'g' << 24,
+ (long)'o' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'o' << 56 | (long)'l' << 48 | (long)'i' << 40 | (long)'n' << 32 | (long)'e' << 24,
+ (long)'o' << 56 | (long)'m' << 48 | (long)'e' << 40 | (long)'g' << 32 | (long)'a' << 24,
+ (long)'o' << 56 | (long)'m' << 48 | (long)'i' << 40 | (long)'c' << 32 | (long)'r' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'o' << 56 | (long)'p' << 48 | (long)'l' << 40 | (long)'u' << 32 | (long)'s' << 24,
+ (long)'o' << 56 | (long)'r' << 48,
+ (long)'o' << 56 | (long)'r' << 48 | (long)'d' << 40 | (long)'f' << 32,
+ (long)'o' << 56 | (long)'r' << 48 | (long)'d' << 40 | (long)'m' << 32,
+ (long)'o' << 56 | (long)'s' << 48 | (long)'l' << 40 | (long)'a' << 32 | (long)'s' << 24 | (long)'h' << 16,
+ (long)'o' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'l' << 32 | (long)'d' << 24 | (long)'e' << 16,
+ (long)'o' << 56 | (long)'t' << 48 | (long)'i' << 40 | (long)'m' << 32 | (long)'e' << 24 | (long)'s' << 16,
+ (long)'o' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'p' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'a' << 32,
+ (long)'p' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'t' << 32,
+ (long)'p' << 56 | (long)'e' << 48 | (long)'r' << 40 | (long)'m' << 32 | (long)'i' << 24 | (long)'l' << 16,
+ (long)'p' << 56 | (long)'e' << 48 | (long)'r' << 40 | (long)'p' << 32,
+ (long)'p' << 56 | (long)'h' << 48 | (long)'i' << 40,
+ (long)'p' << 56 | (long)'i' << 48,
+ (long)'p' << 56 | (long)'i' << 48 | (long)'v' << 40,
+ (long)'p' << 56 | (long)'l' << 48 | (long)'u' << 40 | (long)'s' << 32 | (long)'m' << 24 | (long)'n' << 16,
+ (long)'p' << 56 | (long)'o' << 48 | (long)'u' << 40 | (long)'n' << 32 | (long)'d' << 24,
+ (long)'p' << 56 | (long)'r' << 48 | (long)'i' << 40 | (long)'m' << 32 | (long)'e' << 24,
+ (long)'p' << 56 | (long)'r' << 48 | (long)'o' << 40 | (long)'d' << 32,
+ (long)'p' << 56 | (long)'r' << 48 | (long)'o' << 40 | (long)'p' << 32,
+ (long)'p' << 56 | (long)'s' << 48 | (long)'i' << 40,
+ (long)'q' << 56 | (long)'u' << 48 | (long)'o' << 40 | (long)'t' << 32,
+ (long)'r' << 56 | (long)'A' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'r' << 56 | (long)'a' << 48 | (long)'d' << 40 | (long)'i' << 32 | (long)'c' << 24,
+ (long)'r' << 56 | (long)'a' << 48 | (long)'n' << 40 | (long)'g' << 32,
+ (long)'r' << 56 | (long)'a' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'r' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'r' << 56 | (long)'c' << 48 | (long)'e' << 40 | (long)'i' << 32 | (long)'l' << 24,
+ (long)'r' << 56 | (long)'d' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'r' << 56 | (long)'e' << 48 | (long)'a' << 40 | (long)'l' << 32,
+ (long)'r' << 56 | (long)'e' << 48 | (long)'g' << 40,
+ (long)'r' << 56 | (long)'f' << 48 | (long)'l' << 40 | (long)'o' << 32 | (long)'o' << 24 | (long)'r' << 16,
+ (long)'r' << 56 | (long)'h' << 48 | (long)'o' << 40,
+ (long)'r' << 56 | (long)'l' << 48 | (long)'m' << 40,
+ (long)'r' << 56 | (long)'s' << 48 | (long)'a' << 40 | (long)'q' << 32 | (long)'u' << 24 | (long)'o' << 16,
+ (long)'r' << 56 | (long)'s' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'s' << 56 | (long)'b' << 48 | (long)'q' << 40 | (long)'u' << 32 | (long)'o' << 24,
+ (long)'s' << 56 | (long)'c' << 48 | (long)'a' << 40 | (long)'r' << 32 | (long)'o' << 24 | (long)'n' << 16,
+ (long)'s' << 56 | (long)'d' << 48 | (long)'o' << 40 | (long)'t' << 32,
+ (long)'s' << 56 | (long)'e' << 48 | (long)'c' << 40 | (long)'t' << 32,
+ (long)'s' << 56 | (long)'h' << 48 | (long)'y' << 40,
+ (long)'s' << 56 | (long)'i' << 48 | (long)'g' << 40 | (long)'m' << 32 | (long)'a' << 24,
+ (long)'s' << 56 | (long)'i' << 48 | (long)'g' << 40 | (long)'m' << 32 | (long)'a' << 24 | (long)'f' << 16,
+ (long)'s' << 56 | (long)'i' << 48 | (long)'m' << 40,
+ (long)'s' << 56 | (long)'p' << 48 | (long)'a' << 40 | (long)'d' << 32 | (long)'e' << 24 | (long)'s' << 16,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'b' << 40,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'b' << 40 | (long)'e' << 32,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'m' << 40,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'p' << 40,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'p' << 40 | (long)'1' << 32,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'p' << 40 | (long)'2' << 32,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'p' << 40 | (long)'3' << 32,
+ (long)'s' << 56 | (long)'u' << 48 | (long)'p' << 40 | (long)'e' << 32,
+ (long)'s' << 56 | (long)'z' << 48 | (long)'l' << 40 | (long)'i' << 32 | (long)'g' << 24,
+ (long)'t' << 56 | (long)'a' << 48 | (long)'u' << 40,
+ (long)'t' << 56 | (long)'h' << 48 | (long)'e' << 40 | (long)'r' << 32 | (long)'e' << 24 | (long)'4' << 16,
+ (long)'t' << 56 | (long)'h' << 48 | (long)'e' << 40 | (long)'t' << 32 | (long)'a' << 24,
+ (long)'t' << 56 | (long)'h' << 48 | (long)'e' << 40 | (long)'t' << 32 | (long)'a' << 24 | (long)'s' << 16 | (long)'y' << 8 | (long)'m' << 0,
+ (long)'t' << 56 | (long)'h' << 48 | (long)'i' << 40 | (long)'n' << 32 | (long)'s' << 24 | (long)'p' << 16,
+ (long)'t' << 56 | (long)'h' << 48 | (long)'o' << 40 | (long)'r' << 32 | (long)'n' << 24,
+ (long)'t' << 56 | (long)'i' << 48 | (long)'l' << 40 | (long)'d' << 32 | (long)'e' << 24,
+ (long)'t' << 56 | (long)'i' << 48 | (long)'m' << 40 | (long)'e' << 32 | (long)'s' << 24,
+ (long)'t' << 56 | (long)'r' << 48 | (long)'a' << 40 | (long)'d' << 32 | (long)'e' << 24,
+ (long)'u' << 56 | (long)'A' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'u' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'u' << 56 | (long)'a' << 48 | (long)'r' << 40 | (long)'r' << 32,
+ (long)'u' << 56 | (long)'c' << 48 | (long)'i' << 40 | (long)'r' << 32 | (long)'c' << 24,
+ (long)'u' << 56 | (long)'g' << 48 | (long)'r' << 40 | (long)'a' << 32 | (long)'v' << 24 | (long)'e' << 16,
+ (long)'u' << 56 | (long)'m' << 48 | (long)'l' << 40,
+ (long)'u' << 56 | (long)'p' << 48 | (long)'s' << 40 | (long)'i' << 32 | (long)'h' << 24,
+ (long)'u' << 56 | (long)'p' << 48 | (long)'s' << 40 | (long)'i' << 32 | (long)'l' << 24 | (long)'o' << 16 | (long)'n' << 8,
+ (long)'u' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'w' << 56 | (long)'e' << 48 | (long)'i' << 40 | (long)'e' << 32 | (long)'r' << 24 | (long)'p' << 16,
+ (long)'x' << 56 | (long)'i' << 48,
+ (long)'y' << 56 | (long)'a' << 48 | (long)'c' << 40 | (long)'u' << 32 | (long)'t' << 24 | (long)'e' << 16,
+ (long)'y' << 56 | (long)'e' << 48 | (long)'n' << 40,
+ (long)'y' << 56 | (long)'u' << 48 | (long)'m' << 40 | (long)'l' << 32,
+ (long)'z' << 56 | (long)'e' << 48 | (long)'t' << 40 | (long)'a' << 32,
+ (long)'z' << 56 | (long)'w' << 48 | (long)'j' << 40,
+ (long)'z' << 56 | (long)'w' << 48 | (long)'n' << 40 | (long)'j' << 32
+ };
+
+ static readonly char[] entities_values = new char[] {
+ '\u00C6',
+ '\u00C1',
+ '\u00C2',
+ '\u00C0',
+ '\u0391',
+ '\u00C5',
+ '\u00C3',
+ '\u00C4',
+ '\u0392',
+ '\u00C7',
+ '\u03A7',
+ '\u2021',
+ '\u0394',
+ '\u00D0',
+ '\u00C9',
+ '\u00CA',
+ '\u00C8',
+ '\u0395',
+ '\u0397',
+ '\u00CB',
+ '\u0393',
+ '\u00CD',
+ '\u00CE',
+ '\u00CC',
+ '\u0399',
+ '\u00CF',
+ '\u039A',
+ '\u039B',
+ '\u039C',
+ '\u00D1',
+ '\u039D',
+ '\u0152',
+ '\u00D3',
+ '\u00D4',
+ '\u00D2',
+ '\u03A9',
+ '\u039F',
+ '\u00D8',
+ '\u00D5',
+ '\u00D6',
+ '\u03A6',
+ '\u03A0',
+ '\u2033',
+ '\u03A8',
+ '\u03A1',
+ '\u0160',
+ '\u03A3',
+ '\u00DE',
+ '\u03A4',
+ '\u0398',
+ '\u00DA',
+ '\u00DB',
+ '\u00D9',
+ '\u03A5',
+ '\u00DC',
+ '\u039E',
+ '\u00DD',
+ '\u0178',
+ '\u0396',
+ '\u00E1',
+ '\u00E2',
+ '\u00B4',
+ '\u00E6',
+ '\u00E0',
+ '\u2135',
+ '\u03B1',
+ '\u0026',
+ '\u2227',
+ '\u2220',
+ '\u0027',
+ '\u00E5',
+ '\u2248',
+ '\u00E3',
+ '\u00E4',
+ '\u201E',
+ '\u03B2',
+ '\u00A6',
+ '\u2022',
+ '\u2229',
+ '\u00E7',
+ '\u00B8',
+ '\u00A2',
+ '\u03C7',
+ '\u02C6',
+ '\u2663',
+ '\u2245',
+ '\u00A9',
+ '\u21B5',
+ '\u222A',
+ '\u00A4',
+ '\u21D3',
+ '\u2020',
+ '\u2193',
+ '\u00B0',
+ '\u03B4',
+ '\u2666',
+ '\u00F7',
+ '\u00E9',
+ '\u00EA',
+ '\u00E8',
+ '\u2205',
+ '\u2003',
+ '\u2002',
+ '\u03B5',
+ '\u2261',
+ '\u03B7',
+ '\u00F0',
+ '\u00EB',
+ '\u20AC',
+ '\u2203',
+ '\u0192',
+ '\u2200',
+ '\u00BD',
+ '\u00BC',
+ '\u00BE',
+ '\u2044',
+ '\u03B3',
+ '\u2265',
+ '\u003E',
+ '\u21D4',
+ '\u2194',
+ '\u2665',
+ '\u2026',
+ '\u00ED',
+ '\u00EE',
+ '\u00A1',
+ '\u00EC',
+ '\u2111',
+ '\u221E',
+ '\u222B',
+ '\u03B9',
+ '\u00BF',
+ '\u2208',
+ '\u00EF',
+ '\u03BA',
+ '\u21D0',
+ '\u03BB',
+ '\u2329',
+ '\u00AB',
+ '\u2190',
+ '\u2308',
+ '\u201C',
+ '\u2264',
+ '\u230A',
+ '\u2217',
+ '\u25CA',
+ '\u200E',
+ '\u2039',
+ '\u2018',
+ '\u003C',
+ '\u00AF',
+ '\u2014',
+ '\u00B5',
+ '\u00B7',
+ '\u2212',
+ '\u03BC',
+ '\u2207',
+ '\u00A0',
+ '\u2013',
+ '\u2260',
+ '\u220B',
+ '\u00AC',
+ '\u2209',
+ '\u2284',
+ '\u00F1',
+ '\u03BD',
+ '\u00F3',
+ '\u00F4',
+ '\u0153',
+ '\u00F2',
+ '\u203E',
+ '\u03C9',
+ '\u03BF',
+ '\u2295',
+ '\u2228',
+ '\u00AA',
+ '\u00BA',
+ '\u00F8',
+ '\u00F5',
+ '\u2297',
+ '\u00F6',
+ '\u00B6',
+ '\u2202',
+ '\u2030',
+ '\u22A5',
+ '\u03C6',
+ '\u03C0',
+ '\u03D6',
+ '\u00B1',
+ '\u00A3',
+ '\u2032',
+ '\u220F',
+ '\u221D',
+ '\u03C8',
+ '\u0022',
+ '\u21D2',
+ '\u221A',
+ '\u232A',
+ '\u00BB',
+ '\u2192',
+ '\u2309',
+ '\u201D',
+ '\u211C',
+ '\u00AE',
+ '\u230B',
+ '\u03C1',
+ '\u200F',
+ '\u203A',
+ '\u2019',
+ '\u201A',
+ '\u0161',
+ '\u22C5',
+ '\u00A7',
+ '\u00AD',
+ '\u03C3',
+ '\u03C2',
+ '\u223C',
+ '\u2660',
+ '\u2282',
+ '\u2286',
+ '\u2211',
+ '\u2283',
+ '\u00B9',
+ '\u00B2',
+ '\u00B3',
+ '\u2287',
+ '\u00DF',
+ '\u03C4',
+ '\u2234',
+ '\u03B8',
+ '\u03D1',
+ '\u2009',
+ '\u00FE',
+ '\u02DC',
+ '\u00D7',
+ '\u2122',
+ '\u21D1',
+ '\u00FA',
+ '\u2191',
+ '\u00FB',
+ '\u00F9',
+ '\u00A8',
+ '\u03D2',
+ '\u03C5',
+ '\u00FC',
+ '\u2118',
+ '\u03BE',
+ '\u00FD',
+ '\u00A5',
+ '\u00FF',
+ '\u03B6',
+ '\u200D',
+ '\u200C'
+ };
+
+ #region Methods
+
+ static void WriteCharBytes(IList buf, char ch, Encoding e)
+ {
+ if (ch > 255)
+ {
+ foreach (byte b in e.GetBytes(new char[] { ch }))
+ buf.Add(b);
+ }
+ else
+ buf.Add((byte)ch);
+ }
+
+ public static string UrlDecode(string s, Encoding e)
+ {
+ if (null == s)
+ return null;
+
+ if (s.IndexOf('%') == -1 && s.IndexOf('+') == -1)
+ return s;
+
+ if (e == null)
+ e = Encoding.UTF8;
+
+ long len = s.Length;
+ var bytes = new List<byte>();
+ int xchar;
+ char ch;
+
+ for (int i = 0; i < len; i++)
+ {
+ ch = s[i];
+ if (ch == '%' && i + 2 < len && s[i + 1] != '%')
+ {
+ if (s[i + 1] == 'u' && i + 5 < len)
+ {
+ // unicode hex sequence
+ xchar = GetChar(s, i + 2, 4);
+ if (xchar != -1)
+ {
+ WriteCharBytes(bytes, (char)xchar, e);
+ i += 5;
+ }
+ else
+ WriteCharBytes(bytes, '%', e);
+ }
+ else if ((xchar = GetChar(s, i + 1, 2)) != -1)
+ {
+ WriteCharBytes(bytes, (char)xchar, e);
+ i += 2;
+ }
+ else
+ {
+ WriteCharBytes(bytes, '%', e);
+ }
+ continue;
+ }
+
+ if (ch == '+')
+ WriteCharBytes(bytes, ' ', e);
+ else
+ WriteCharBytes(bytes, ch, e);
+ }
+
+ byte[] buf = bytes.ToArray();
+ bytes = null;
+ return e.GetString(buf, 0, buf.Length);
+
+ }
+
+ static int GetInt(byte b)
+ {
+ char c = (char)b;
+ if (c >= '0' && c <= '9')
+ return c - '0';
+
+ if (c >= 'a' && c <= 'f')
+ return c - 'a' + 10;
+
+ if (c >= 'A' && c <= 'F')
+ return c - 'A' + 10;
+
+ return -1;
+ }
+
+ static int GetChar(string str, int offset, int length)
+ {
+ int val = 0;
+ int end = length + offset;
+ for (int i = offset; i < end; i++)
+ {
+ char c = str[i];
+ if (c > 127)
+ return -1;
+
+ int current = GetInt((byte)c);
+ if (current == -1)
+ return -1;
+ val = (val << 4) + current;
+ }
+
+ return val;
+ }
+
+ static bool TryConvertKeyToEntity(string key, out char value)
+ {
+ var token = CalculateKeyValue(key);
+ if (token == 0)
+ {
+ value = '\0';
+ return false;
+ }
+
+ var idx = Array.BinarySearch(entities, token);
+ if (idx < 0)
+ {
+ value = '\0';
+ return false;
+ }
+
+ value = entities_values[idx];
+ return true;
+ }
+
+ static long CalculateKeyValue(string s)
+ {
+ if (s.Length > 8)
+ return 0;
+
+ long key = 0;
+ for (int i = 0; i < s.Length; ++i)
+ {
+ long ch = s[i];
+ if (ch > 'z' || ch < '0')
+ return 0;
+
+ key |= ch << ((7 - i) * 8);
+ }
+
+ return key;
+ }
+
+ /// <summary>
+ /// Decodes an HTML-encoded string and returns the decoded string.
+ /// </summary>
+ /// <param name="s">The HTML string to decode. </param>
+ /// <returns>The decoded text.</returns>
+ public static string HtmlDecode(string s)
+ {
+ if (s == null)
+ throw new ArgumentNullException("s");
+
+ if (s.IndexOf('&') == -1)
+ return s;
+
+ StringBuilder entity = new StringBuilder();
+ StringBuilder output = new StringBuilder();
+ int len = s.Length;
+ // 0 -> nothing,
+ // 1 -> right after '&'
+ // 2 -> between '&' and ';' but no '#'
+ // 3 -> '#' found after '&' and getting numbers
+ int state = 0;
+ int number = 0;
+ int digit_start = 0;
+ bool hex_number = false;
+
+ for (int i = 0; i < len; i++)
+ {
+ char c = s[i];
+ if (state == 0)
+ {
+ if (c == '&')
+ {
+ entity.Append(c);
+ state = 1;
+ }
+ else
+ {
+ output.Append(c);
+ }
+ continue;
+ }
+
+ if (c == '&')
+ {
+ state = 1;
+ if (digit_start > 0)
+ {
+ entity.Append(s, digit_start, i - digit_start);
+ digit_start = 0;
+ }
+
+ output.Append(entity.ToString());
+ entity.Length = 0;
+ entity.Append('&');
+ continue;
+ }
+
+ switch (state)
+ {
+ case 1:
+ if (c == ';')
+ {
+ state = 0;
+ output.Append(entity.ToString());
+ output.Append(c);
+ entity.Length = 0;
+ break;
+ }
+
+ number = 0;
+ hex_number = false;
+ if (c != '#')
+ {
+ state = 2;
+ }
+ else
+ {
+ state = 3;
+ }
+ entity.Append(c);
+
+ break;
+ case 2:
+ entity.Append(c);
+ if (c == ';')
+ {
+ string key = entity.ToString();
+ state = 0;
+ entity.Length = 0;
+
+ if (key.Length > 1)
+ {
+ var skey = key.Substring(1, key.Length - 2);
+ if (TryConvertKeyToEntity(skey, out c))
+ {
+ output.Append(c);
+ break;
+ }
+ }
+
+ output.Append(key);
+ }
+
+ break;
+ case 3:
+ if (c == ';')
+ {
+ if (number < 0x10000)
+ {
+ output.Append((char)number);
+ }
+ else
+ {
+ output.Append((char)(0xd800 + ((number - 0x10000) >> 10)));
+ output.Append((char)(0xdc00 + ((number - 0x10000) & 0x3ff)));
+ }
+ state = 0;
+ entity.Length = 0;
+ digit_start = 0;
+ break;
+ }
+
+ if (c == 'x' || c == 'X' && !hex_number)
+ {
+ digit_start = i;
+ hex_number = true;
+ break;
+ }
+
+ if (Char.IsDigit(c))
+ {
+ if (digit_start == 0)
+ digit_start = i;
+
+ number = number * (hex_number ? 16 : 10) + ((int)c - '0');
+ break;
+ }
+
+ if (hex_number)
+ {
+ if (c >= 'a' && c <= 'f')
+ {
+ number = number * 16 + 10 + ((int)c - 'a');
+ break;
+ }
+ if (c >= 'A' && c <= 'F')
+ {
+ number = number * 16 + 10 + ((int)c - 'A');
+ break;
+ }
+ }
+
+ state = 2;
+ if (digit_start > 0)
+ {
+ entity.Append(s, digit_start, i - digit_start);
+ digit_start = 0;
+ }
+
+ entity.Append(c);
+ break;
+ }
+ }
+
+ if (entity.Length > 0)
+ {
+ output.Append(entity);
+ }
+ else if (digit_start > 0)
+ {
+ output.Append(s, digit_start, s.Length - digit_start);
+ }
+ return output.ToString();
+ }
+
+ public static QueryParamCollection ParseQueryString(string query)
+ {
+ return ParseQueryString(query, Encoding.UTF8);
+ }
+
+ public static QueryParamCollection ParseQueryString(string query, Encoding encoding)
+ {
+ if (query == null)
+ throw new ArgumentNullException("query");
+ if (encoding == null)
+ throw new ArgumentNullException("encoding");
+ if (query.Length == 0 || (query.Length == 1 && query[0] == '?'))
+ return new QueryParamCollection();
+ if (query[0] == '?')
+ query = query.Substring(1);
+
+ QueryParamCollection result = new QueryParamCollection();
+ ParseQueryString(query, encoding, result);
+ return result;
+ }
+
+ internal static void ParseQueryString(string query, Encoding encoding, QueryParamCollection result)
+ {
+ if (query.Length == 0)
+ return;
+
+ string decoded = HtmlDecode(query);
+ int decodedLength = decoded.Length;
+ int namePos = 0;
+ bool first = true;
+ while (namePos <= decodedLength)
+ {
+ int valuePos = -1, valueEnd = -1;
+ for (int q = namePos; q < decodedLength; q++)
+ {
+ if (valuePos == -1 && decoded[q] == '=')
+ {
+ valuePos = q + 1;
+ }
+ else if (decoded[q] == '&')
+ {
+ valueEnd = q;
+ break;
+ }
+ }
+
+ if (first)
+ {
+ first = false;
+ if (decoded[namePos] == '?')
+ namePos++;
+ }
+
+ string name, value;
+ if (valuePos == -1)
+ {
+ name = null;
+ valuePos = namePos;
+ }
+ else
+ {
+ name = UrlDecode(decoded.Substring(namePos, valuePos - namePos - 1), encoding);
+ }
+ if (valueEnd < 0)
+ {
+ namePos = -1;
+ valueEnd = decoded.Length;
+ }
+ else
+ {
+ namePos = valueEnd + 1;
+ }
+ value = UrlDecode(decoded.Substring(valuePos, valueEnd - valuePos), encoding);
+
+ result.Add(name, value);
+ if (namePos == -1)
+ break;
+ }
+ }
+ #endregion // Methods
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/RequestMono.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/RequestMono.cs
new file mode 100644
index 000000000..ec14c32c8
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/RequestMono.cs
@@ -0,0 +1,846 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Globalization;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public partial class WebSocketSharpRequest : IHttpRequest
+ {
+ static internal string GetParameter(string header, string attr)
+ {
+ int ap = header.IndexOf(attr);
+ if (ap == -1)
+ return null;
+
+ ap += attr.Length;
+ if (ap >= header.Length)
+ return null;
+
+ char ending = header[ap];
+ if (ending != '"')
+ ending = ' ';
+
+ int end = header.IndexOf(ending, ap + 1);
+ if (end == -1)
+ return ending == '"' ? null : header.Substring(ap);
+
+ return header.Substring(ap + 1, end - ap - 1);
+ }
+
+ async Task LoadMultiPart()
+ {
+ string boundary = GetParameter(ContentType, "; boundary=");
+ if (boundary == null)
+ return;
+
+ using (var requestStream = GetSubStream(InputStream, _memoryStreamProvider))
+ {
+ //DB: 30/01/11 - Hack to get around non-seekable stream and received HTTP request
+ //Not ending with \r\n?
+ var ms = _memoryStreamProvider.CreateNew(32 * 1024);
+ await requestStream.CopyToAsync(ms).ConfigureAwait(false);
+
+ var input = ms;
+ ms.WriteByte((byte)'\r');
+ ms.WriteByte((byte)'\n');
+
+ input.Position = 0;
+
+ //Uncomment to debug
+ //var content = new StreamReader(ms).ReadToEnd();
+ //Console.WriteLine(boundary + "::" + content);
+ //input.Position = 0;
+
+ var multi_part = new HttpMultipart(input, boundary, ContentEncoding);
+
+ HttpMultipart.Element e;
+ while ((e = multi_part.ReadNextElement()) != null)
+ {
+ if (e.Filename == null)
+ {
+ byte[] copy = new byte[e.Length];
+
+ input.Position = e.Start;
+ input.Read(copy, 0, (int)e.Length);
+
+ form.Add(e.Name, (e.Encoding ?? ContentEncoding).GetString(copy, 0, copy.Length));
+ }
+ else
+ {
+ //
+ // We use a substream, as in 2.x we will support large uploads streamed to disk,
+ //
+ HttpPostedFile sub = new HttpPostedFile(e.Filename, e.ContentType, input, e.Start, e.Length);
+ files[e.Name] = sub;
+ }
+ }
+ }
+ }
+
+ public QueryParamCollection Form
+ {
+ get
+ {
+ if (form == null)
+ {
+ form = new WebROCollection();
+ files = new Dictionary<string, HttpPostedFile>();
+
+ if (IsContentType("multipart/form-data", true))
+ {
+ var task = LoadMultiPart();
+ Task.WaitAll(task);
+ }
+ else if (IsContentType("application/x-www-form-urlencoded", true))
+ {
+ var task = LoadWwwForm();
+ Task.WaitAll(task);
+ }
+
+ form.Protect();
+ }
+
+#if NET_4_0
+ if (validateRequestNewMode && !checked_form) {
+ // Setting this before calling the validator prevents
+ // possible endless recursion
+ checked_form = true;
+ ValidateNameValueCollection ("Form", query_string_nvc, RequestValidationSource.Form);
+ } else
+#endif
+ if (validate_form && !checked_form)
+ {
+ checked_form = true;
+ ValidateNameValueCollection("Form", form);
+ }
+
+ return form;
+ }
+ }
+
+ public string Accept
+ {
+ get
+ {
+ return string.IsNullOrEmpty(request.Headers["Accept"]) ? null : request.Headers["Accept"];
+ }
+ }
+
+ public string Authorization
+ {
+ get
+ {
+ return string.IsNullOrEmpty(request.Headers["Authorization"]) ? null : request.Headers["Authorization"];
+ }
+ }
+
+ protected bool validate_cookies, validate_query_string, validate_form;
+ protected bool checked_cookies, checked_query_string, checked_form;
+
+ static void ThrowValidationException(string name, string key, string value)
+ {
+ string v = "\"" + value + "\"";
+ if (v.Length > 20)
+ v = v.Substring(0, 16) + "...\"";
+
+ string msg = String.Format("A potentially dangerous Request.{0} value was " +
+ "detected from the client ({1}={2}).", name, key, v);
+
+ throw new Exception(msg);
+ }
+
+ static void ValidateNameValueCollection(string name, QueryParamCollection coll)
+ {
+ if (coll == null)
+ return;
+
+ foreach (var pair in coll)
+ {
+ var key = pair.Name;
+ var val = pair.Value;
+ if (val != null && val.Length > 0 && IsInvalidString(val))
+ ThrowValidationException(name, key, val);
+ }
+ }
+
+ internal static bool IsInvalidString(string val)
+ {
+ int validationFailureIndex;
+
+ return IsInvalidString(val, out validationFailureIndex);
+ }
+
+ internal static bool IsInvalidString(string val, out int validationFailureIndex)
+ {
+ validationFailureIndex = 0;
+
+ int len = val.Length;
+ if (len < 2)
+ return false;
+
+ char current = val[0];
+ for (int idx = 1; idx < len; idx++)
+ {
+ char next = val[idx];
+ // See http://secunia.com/advisories/14325
+ if (current == '<' || current == '\xff1c')
+ {
+ if (next == '!' || next < ' '
+ || (next >= 'a' && next <= 'z')
+ || (next >= 'A' && next <= 'Z'))
+ {
+ validationFailureIndex = idx - 1;
+ return true;
+ }
+ }
+ else if (current == '&' && next == '#')
+ {
+ validationFailureIndex = idx - 1;
+ return true;
+ }
+
+ current = next;
+ }
+
+ return false;
+ }
+
+ public void ValidateInput()
+ {
+ validate_cookies = true;
+ validate_query_string = true;
+ validate_form = true;
+ }
+
+ bool IsContentType(string ct, bool starts_with)
+ {
+ if (ct == null || ContentType == null) return false;
+
+ if (starts_with)
+ return StrUtils.StartsWith(ContentType, ct, true);
+
+ return string.Equals(ContentType, ct, StringComparison.OrdinalIgnoreCase);
+ }
+
+ async Task LoadWwwForm()
+ {
+ using (Stream input = GetSubStream(InputStream, _memoryStreamProvider))
+ {
+ using (var ms = _memoryStreamProvider.CreateNew())
+ {
+ await input.CopyToAsync(ms).ConfigureAwait(false);
+ ms.Position = 0;
+
+ using (StreamReader s = new StreamReader(ms, ContentEncoding))
+ {
+ StringBuilder key = new StringBuilder();
+ StringBuilder value = new StringBuilder();
+ int c;
+
+ while ((c = s.Read()) != -1)
+ {
+ if (c == '=')
+ {
+ value.Length = 0;
+ while ((c = s.Read()) != -1)
+ {
+ if (c == '&')
+ {
+ AddRawKeyValue(key, value);
+ break;
+ }
+ else
+ value.Append((char)c);
+ }
+ if (c == -1)
+ {
+ AddRawKeyValue(key, value);
+ return;
+ }
+ }
+ else if (c == '&')
+ AddRawKeyValue(key, value);
+ else
+ key.Append((char)c);
+ }
+ if (c == -1)
+ AddRawKeyValue(key, value);
+ }
+ }
+ }
+ }
+
+ void AddRawKeyValue(StringBuilder key, StringBuilder value)
+ {
+ string decodedKey = WebUtility.UrlDecode(key.ToString());
+ form.Add(decodedKey,
+ WebUtility.UrlDecode(value.ToString()));
+
+ key.Length = 0;
+ value.Length = 0;
+ }
+
+ WebROCollection form;
+
+ Dictionary<string, HttpPostedFile> files;
+
+ class WebROCollection : QueryParamCollection
+ {
+ bool got_id;
+ int id;
+
+ public bool GotID
+ {
+ get { return got_id; }
+ }
+
+ public int ID
+ {
+ get { return id; }
+ set
+ {
+ got_id = true;
+ id = value;
+ }
+ }
+ public void Protect()
+ {
+ //IsReadOnly = true;
+ }
+
+ public void Unprotect()
+ {
+ //IsReadOnly = false;
+ }
+
+ public override string ToString()
+ {
+ StringBuilder result = new StringBuilder();
+ foreach (var pair in this)
+ {
+ if (result.Length > 0)
+ result.Append('&');
+
+ var key = pair.Name;
+ if (key != null && key.Length > 0)
+ {
+ result.Append(key);
+ result.Append('=');
+ }
+ result.Append(pair.Value);
+ }
+
+ return result.ToString();
+ }
+ }
+
+ public sealed class HttpPostedFile
+ {
+ string name;
+ string content_type;
+ Stream stream;
+
+ class ReadSubStream : Stream
+ {
+ Stream s;
+ long offset;
+ long end;
+ long position;
+
+ public ReadSubStream(Stream s, long offset, long length)
+ {
+ this.s = s;
+ this.offset = offset;
+ this.end = offset + length;
+ position = offset;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int dest_offset, int count)
+ {
+ if (buffer == null)
+ throw new ArgumentNullException("buffer");
+
+ if (dest_offset < 0)
+ throw new ArgumentOutOfRangeException("dest_offset", "< 0");
+
+ if (count < 0)
+ throw new ArgumentOutOfRangeException("count", "< 0");
+
+ int len = buffer.Length;
+ if (dest_offset > len)
+ throw new ArgumentException("destination offset is beyond array size");
+ // reordered to avoid possible integer overflow
+ if (dest_offset > len - count)
+ throw new ArgumentException("Reading would overrun buffer");
+
+ if (count > end - position)
+ count = (int)(end - position);
+
+ if (count <= 0)
+ return 0;
+
+ s.Position = position;
+ int result = s.Read(buffer, dest_offset, count);
+ if (result > 0)
+ position += result;
+ else
+ position = end;
+
+ return result;
+ }
+
+ public override int ReadByte()
+ {
+ if (position >= end)
+ return -1;
+
+ s.Position = position;
+ int result = s.ReadByte();
+ if (result < 0)
+ position = end;
+ else
+ position++;
+
+ return result;
+ }
+
+ public override long Seek(long d, SeekOrigin origin)
+ {
+ long real;
+ switch (origin)
+ {
+ case SeekOrigin.Begin:
+ real = offset + d;
+ break;
+ case SeekOrigin.End:
+ real = end + d;
+ break;
+ case SeekOrigin.Current:
+ real = position + d;
+ break;
+ default:
+ throw new ArgumentException();
+ }
+
+ long virt = real - offset;
+ if (virt < 0 || virt > Length)
+ throw new ArgumentException();
+
+ position = s.Seek(real, SeekOrigin.Begin);
+ return position;
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override bool CanRead
+ {
+ get { return true; }
+ }
+ public override bool CanSeek
+ {
+ get { return true; }
+ }
+ public override bool CanWrite
+ {
+ get { return false; }
+ }
+
+ public override long Length
+ {
+ get { return end - offset; }
+ }
+
+ public override long Position
+ {
+ get
+ {
+ return position - offset;
+ }
+ set
+ {
+ if (value > Length)
+ throw new ArgumentOutOfRangeException();
+
+ position = Seek(value, SeekOrigin.Begin);
+ }
+ }
+ }
+
+ internal HttpPostedFile(string name, string content_type, Stream base_stream, long offset, long length)
+ {
+ this.name = name;
+ this.content_type = content_type;
+ this.stream = new ReadSubStream(base_stream, offset, length);
+ }
+
+ public string ContentType
+ {
+ get
+ {
+ return content_type;
+ }
+ }
+
+ public int ContentLength
+ {
+ get
+ {
+ return (int)stream.Length;
+ }
+ }
+
+ public string FileName
+ {
+ get
+ {
+ return name;
+ }
+ }
+
+ public Stream InputStream
+ {
+ get
+ {
+ return stream;
+ }
+ }
+ }
+
+ class Helpers
+ {
+ public static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture;
+ }
+
+ internal sealed class StrUtils
+ {
+ public static bool StartsWith(string str1, string str2, bool ignore_case)
+ {
+ if (string.IsNullOrWhiteSpace(str1))
+ {
+ return false;
+ }
+
+ var comparison = ignore_case ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ return str1.IndexOf(str2, comparison) == 0;
+ }
+
+ public static bool EndsWith(string str1, string str2, bool ignore_case)
+ {
+ int l2 = str2.Length;
+ if (l2 == 0)
+ return true;
+
+ int l1 = str1.Length;
+ if (l2 > l1)
+ return false;
+
+ var comparison = ignore_case ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ return str1.IndexOf(str2, comparison) == str1.Length - str2.Length - 1;
+ }
+ }
+
+ class HttpMultipart
+ {
+
+ public class Element
+ {
+ public string ContentType;
+ public string Name;
+ public string Filename;
+ public Encoding Encoding;
+ public long Start;
+ public long Length;
+
+ public override string ToString()
+ {
+ return "ContentType " + ContentType + ", Name " + Name + ", Filename " + Filename + ", Start " +
+ Start.ToString() + ", Length " + Length.ToString();
+ }
+ }
+
+ Stream data;
+ string boundary;
+ byte[] boundary_bytes;
+ byte[] buffer;
+ bool at_eof;
+ Encoding encoding;
+ StringBuilder sb;
+
+ const byte HYPHEN = (byte)'-', LF = (byte)'\n', CR = (byte)'\r';
+
+ // See RFC 2046
+ // In the case of multipart entities, in which one or more different
+ // sets of data are combined in a single body, a "multipart" media type
+ // field must appear in the entity's header. The body must then contain
+ // one or more body parts, each preceded by a boundary delimiter line,
+ // and the last one followed by a closing boundary delimiter line.
+ // After its boundary delimiter line, each body part then consists of a
+ // header area, a blank line, and a body area. Thus a body part is
+ // similar to an RFC 822 message in syntax, but different in meaning.
+
+ public HttpMultipart(Stream data, string b, Encoding encoding)
+ {
+ this.data = data;
+ //DB: 30/01/11: cannot set or read the Position in HttpListener in Win.NET
+ //var ms = new MemoryStream(32 * 1024);
+ //data.CopyTo(ms);
+ //this.data = ms;
+
+ boundary = b;
+ boundary_bytes = encoding.GetBytes(b);
+ buffer = new byte[boundary_bytes.Length + 2]; // CRLF or '--'
+ this.encoding = encoding;
+ sb = new StringBuilder();
+ }
+
+ string ReadLine()
+ {
+ // CRLF or LF are ok as line endings.
+ bool got_cr = false;
+ int b = 0;
+ sb.Length = 0;
+ while (true)
+ {
+ b = data.ReadByte();
+ if (b == -1)
+ {
+ return null;
+ }
+
+ if (b == LF)
+ {
+ break;
+ }
+ got_cr = b == CR;
+ sb.Append((char)b);
+ }
+
+ if (got_cr)
+ sb.Length--;
+
+ return sb.ToString();
+
+ }
+
+ static string GetContentDispositionAttribute(string l, string name)
+ {
+ int idx = l.IndexOf(name + "=\"");
+ if (idx < 0)
+ return null;
+ int begin = idx + name.Length + "=\"".Length;
+ int end = l.IndexOf('"', begin);
+ if (end < 0)
+ return null;
+ if (begin == end)
+ return "";
+ return l.Substring(begin, end - begin);
+ }
+
+ string GetContentDispositionAttributeWithEncoding(string l, string name)
+ {
+ int idx = l.IndexOf(name + "=\"");
+ if (idx < 0)
+ return null;
+ int begin = idx + name.Length + "=\"".Length;
+ int end = l.IndexOf('"', begin);
+ if (end < 0)
+ return null;
+ if (begin == end)
+ return "";
+
+ string temp = l.Substring(begin, end - begin);
+ byte[] source = new byte[temp.Length];
+ for (int i = temp.Length - 1; i >= 0; i--)
+ source[i] = (byte)temp[i];
+
+ return encoding.GetString(source, 0, source.Length);
+ }
+
+ bool ReadBoundary()
+ {
+ try
+ {
+ string line = ReadLine();
+ while (line == "")
+ line = ReadLine();
+ if (line[0] != '-' || line[1] != '-')
+ return false;
+
+ if (!StrUtils.EndsWith(line, boundary, false))
+ return true;
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+
+ string ReadHeaders()
+ {
+ string s = ReadLine();
+ if (s == "")
+ return null;
+
+ return s;
+ }
+
+ bool CompareBytes(byte[] orig, byte[] other)
+ {
+ for (int i = orig.Length - 1; i >= 0; i--)
+ if (orig[i] != other[i])
+ return false;
+
+ return true;
+ }
+
+ long MoveToNextBoundary()
+ {
+ long retval = 0;
+ bool got_cr = false;
+
+ int state = 0;
+ int c = data.ReadByte();
+ while (true)
+ {
+ if (c == -1)
+ return -1;
+
+ if (state == 0 && c == LF)
+ {
+ retval = data.Position - 1;
+ if (got_cr)
+ retval--;
+ state = 1;
+ c = data.ReadByte();
+ }
+ else if (state == 0)
+ {
+ got_cr = c == CR;
+ c = data.ReadByte();
+ }
+ else if (state == 1 && c == '-')
+ {
+ c = data.ReadByte();
+ if (c == -1)
+ return -1;
+
+ if (c != '-')
+ {
+ state = 0;
+ got_cr = false;
+ continue; // no ReadByte() here
+ }
+
+ int nread = data.Read(buffer, 0, buffer.Length);
+ int bl = buffer.Length;
+ if (nread != bl)
+ return -1;
+
+ if (!CompareBytes(boundary_bytes, buffer))
+ {
+ state = 0;
+ data.Position = retval + 2;
+ if (got_cr)
+ {
+ data.Position++;
+ got_cr = false;
+ }
+ c = data.ReadByte();
+ continue;
+ }
+
+ if (buffer[bl - 2] == '-' && buffer[bl - 1] == '-')
+ {
+ at_eof = true;
+ }
+ else if (buffer[bl - 2] != CR || buffer[bl - 1] != LF)
+ {
+ state = 0;
+ data.Position = retval + 2;
+ if (got_cr)
+ {
+ data.Position++;
+ got_cr = false;
+ }
+ c = data.ReadByte();
+ continue;
+ }
+ data.Position = retval + 2;
+ if (got_cr)
+ data.Position++;
+ break;
+ }
+ else
+ {
+ // state == 1
+ state = 0; // no ReadByte() here
+ }
+ }
+
+ return retval;
+ }
+
+ public Element ReadNextElement()
+ {
+ if (at_eof || ReadBoundary())
+ return null;
+
+ Element elem = new Element();
+ string header;
+ while ((header = ReadHeaders()) != null)
+ {
+ if (StrUtils.StartsWith(header, "Content-Disposition:", true))
+ {
+ elem.Name = GetContentDispositionAttribute(header, "name");
+ elem.Filename = StripPath(GetContentDispositionAttributeWithEncoding(header, "filename"));
+ }
+ else if (StrUtils.StartsWith(header, "Content-Type:", true))
+ {
+ elem.ContentType = header.Substring("Content-Type:".Length).Trim();
+ elem.Encoding = GetEncoding(elem.ContentType);
+ }
+ }
+
+ long start = 0;
+ start = data.Position;
+ elem.Start = start;
+ long pos = MoveToNextBoundary();
+ if (pos == -1)
+ return null;
+
+ elem.Length = pos - start;
+ return elem;
+ }
+
+ static string StripPath(string path)
+ {
+ if (path == null || path.Length == 0)
+ return path;
+
+ if (path.IndexOf(":\\") != 1 && !path.StartsWith("\\\\"))
+ return path;
+ return path.Substring(path.LastIndexOf('\\') + 1);
+ }
+ }
+
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/SharpWebSocket.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/SharpWebSocket.cs
new file mode 100644
index 000000000..9823a2ff5
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/SharpWebSocket.cs
@@ -0,0 +1,158 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using WebSocketState = MediaBrowser.Model.Net.WebSocketState;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public class SharpWebSocket : IWebSocket
+ {
+ /// <summary>
+ /// The logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ public event EventHandler<EventArgs> Closed;
+
+ /// <summary>
+ /// Gets or sets the web socket.
+ /// </summary>
+ /// <value>The web socket.</value>
+ private SocketHttpListener.WebSocket WebSocket { get; set; }
+
+ private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
+
+ public SharpWebSocket(SocketHttpListener.WebSocket socket, ILogger logger)
+ {
+ if (socket == null)
+ {
+ throw new ArgumentNullException("socket");
+ }
+
+ if (logger == null)
+ {
+ throw new ArgumentNullException("logger");
+ }
+
+ _logger = logger;
+ WebSocket = socket;
+
+ socket.OnMessage += socket_OnMessage;
+ socket.OnClose += socket_OnClose;
+ socket.OnError += socket_OnError;
+
+ WebSocket.ConnectAsServer();
+ }
+
+ void socket_OnError(object sender, SocketHttpListener.ErrorEventArgs e)
+ {
+ _logger.Error("Error in SharpWebSocket: {0}", e.Message ?? string.Empty);
+ //EventHelper.FireEventIfNotNull(Closed, this, EventArgs.Empty, _logger);
+ }
+
+ void socket_OnClose(object sender, SocketHttpListener.CloseEventArgs e)
+ {
+ EventHelper.FireEventIfNotNull(Closed, this, EventArgs.Empty, _logger);
+ }
+
+ void socket_OnMessage(object sender, SocketHttpListener.MessageEventArgs e)
+ {
+ //if (!string.IsNullOrWhiteSpace(e.Data))
+ //{
+ // if (OnReceive != null)
+ // {
+ // OnReceive(e.Data);
+ // }
+ // return;
+ //}
+ if (OnReceiveBytes != null)
+ {
+ OnReceiveBytes(e.RawData);
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the state.
+ /// </summary>
+ /// <value>The state.</value>
+ public WebSocketState State
+ {
+ get
+ {
+ WebSocketState commonState;
+
+ if (!Enum.TryParse(WebSocket.ReadyState.ToString(), true, out commonState))
+ {
+ _logger.Warn("Unrecognized WebSocketState: {0}", WebSocket.ReadyState.ToString());
+ }
+
+ return commonState;
+ }
+ }
+
+ /// <summary>
+ /// Sends the async.
+ /// </summary>
+ /// <param name="bytes">The bytes.</param>
+ /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendAsync(byte[] bytes, bool endOfMessage, CancellationToken cancellationToken)
+ {
+ return WebSocket.SendAsync(bytes);
+ }
+
+ /// <summary>
+ /// Sends the asynchronous.
+ /// </summary>
+ /// <param name="text">The text.</param>
+ /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendAsync(string text, bool endOfMessage, CancellationToken cancellationToken)
+ {
+ return WebSocket.SendAsync(text);
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ WebSocket.OnMessage -= socket_OnMessage;
+ WebSocket.OnClose -= socket_OnClose;
+ WebSocket.OnError -= socket_OnError;
+
+ _cancellationTokenSource.Cancel();
+
+ WebSocket.Close();
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the receive action.
+ /// </summary>
+ /// <value>The receive action.</value>
+ public Action<byte[]> OnReceiveBytes { get; set; }
+
+ /// <summary>
+ /// Gets or sets the on receive.
+ /// </summary>
+ /// <value>The on receive.</value>
+ public Action<string> OnReceive { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpListener.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpListener.cs
new file mode 100644
index 000000000..4606d0e31
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpListener.cs
@@ -0,0 +1,207 @@
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Logging;
+using SocketHttpListener.Net;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.Text;
+using SocketHttpListener.Primitives;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public class WebSocketSharpListener : IHttpListener
+ {
+ private HttpListener _listener;
+
+ private readonly ILogger _logger;
+ private readonly ICertificate _certificate;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+ private readonly ITextEncoding _textEncoding;
+ private readonly INetworkManager _networkManager;
+ private readonly ISocketFactory _socketFactory;
+ private readonly ICryptoProvider _cryptoProvider;
+ private readonly IStreamFactory _streamFactory;
+ private readonly Func<HttpListenerContext, IHttpRequest> _httpRequestFactory;
+ private readonly bool _enableDualMode;
+
+ public WebSocketSharpListener(ILogger logger, ICertificate certificate, IMemoryStreamFactory memoryStreamProvider, ITextEncoding textEncoding, INetworkManager networkManager, ISocketFactory socketFactory, ICryptoProvider cryptoProvider, IStreamFactory streamFactory, bool enableDualMode, Func<HttpListenerContext, IHttpRequest> httpRequestFactory)
+ {
+ _logger = logger;
+ _certificate = certificate;
+ _memoryStreamProvider = memoryStreamProvider;
+ _textEncoding = textEncoding;
+ _networkManager = networkManager;
+ _socketFactory = socketFactory;
+ _cryptoProvider = cryptoProvider;
+ _streamFactory = streamFactory;
+ _enableDualMode = enableDualMode;
+ _httpRequestFactory = httpRequestFactory;
+ }
+
+ public Action<Exception, IRequest> ErrorHandler { get; set; }
+ public Func<IHttpRequest, Uri, Task> RequestHandler { get; set; }
+
+ public Action<WebSocketConnectingEventArgs> WebSocketConnecting { get; set; }
+
+ public Action<WebSocketConnectEventArgs> WebSocketConnected { get; set; }
+
+ public void Start(IEnumerable<string> urlPrefixes)
+ {
+ if (_listener == null)
+ _listener = new HttpListener(_logger, _cryptoProvider, _streamFactory, _socketFactory, _networkManager, _textEncoding, _memoryStreamProvider);
+
+ _listener.EnableDualMode = _enableDualMode;
+
+ if (_certificate != null)
+ {
+ _listener.LoadCert(_certificate);
+ }
+
+ foreach (var prefix in urlPrefixes)
+ {
+ _logger.Info("Adding HttpListener prefix " + prefix);
+ _listener.Prefixes.Add(prefix);
+ }
+
+ _listener.OnContext = ProcessContext;
+
+ _listener.Start();
+ }
+
+ private void ProcessContext(HttpListenerContext context)
+ {
+ //Task.Factory.StartNew(() => InitTask(context), TaskCreationOptions.DenyChildAttach | TaskCreationOptions.PreferFairness);
+ Task.Run(() => InitTask(context));
+ }
+
+ private Task InitTask(HttpListenerContext context)
+ {
+ IHttpRequest httpReq = null;
+ var request = context.Request;
+
+ try
+ {
+ if (request.IsWebSocketRequest)
+ {
+ LoggerUtils.LogRequest(_logger, request);
+
+ ProcessWebSocketRequest(context);
+ return Task.FromResult(true);
+ }
+
+ httpReq = GetRequest(context);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error processing request", ex);
+
+ httpReq = httpReq ?? GetRequest(context);
+ ErrorHandler(ex, httpReq);
+ return Task.FromResult(true);
+ }
+
+ return RequestHandler(httpReq, request.Url);
+ }
+
+ private void ProcessWebSocketRequest(HttpListenerContext ctx)
+ {
+ try
+ {
+ var endpoint = ctx.Request.RemoteEndPoint.ToString();
+ var url = ctx.Request.RawUrl;
+
+ var connectingArgs = new WebSocketConnectingEventArgs
+ {
+ Url = url,
+ QueryString = ctx.Request.QueryString,
+ Endpoint = endpoint
+ };
+
+ if (WebSocketConnecting != null)
+ {
+ WebSocketConnecting(connectingArgs);
+ }
+
+ if (connectingArgs.AllowConnection)
+ {
+ _logger.Debug("Web socket connection allowed");
+
+ var webSocketContext = ctx.AcceptWebSocket(null);
+
+ if (WebSocketConnected != null)
+ {
+ WebSocketConnected(new WebSocketConnectEventArgs
+ {
+ Url = url,
+ QueryString = ctx.Request.QueryString,
+ WebSocket = new SharpWebSocket(webSocketContext.WebSocket, _logger),
+ Endpoint = endpoint
+ });
+ }
+ }
+ else
+ {
+ _logger.Warn("Web socket connection not allowed");
+ ctx.Response.StatusCode = 401;
+ ctx.Response.Close();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("AcceptWebSocketAsync error", ex);
+ ctx.Response.StatusCode = 500;
+ ctx.Response.Close();
+ }
+ }
+
+ private IHttpRequest GetRequest(HttpListenerContext httpContext)
+ {
+ return _httpRequestFactory(httpContext);
+ }
+
+ public void Stop()
+ {
+ if (_listener != null)
+ {
+ foreach (var prefix in _listener.Prefixes.ToList())
+ {
+ _listener.Prefixes.Remove(prefix);
+ }
+
+ _listener.Close();
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ private bool _disposed;
+ private readonly object _disposeLock = new object();
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed) return;
+
+ lock (_disposeLock)
+ {
+ if (_disposed) return;
+
+ if (disposing)
+ {
+ Stop();
+ }
+
+ //release unmanaged resources here...
+ _disposed = true;
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpRequest.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpRequest.cs
new file mode 100644
index 000000000..b3fcde745
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpRequest.cs
@@ -0,0 +1,620 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using Emby.Server.Implementations.HttpServer;
+using Emby.Server.Implementations.HttpServer.SocketSharp;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Services;
+using SocketHttpListener.Net;
+using IHttpFile = MediaBrowser.Model.Services.IHttpFile;
+using IHttpRequest = MediaBrowser.Model.Services.IHttpRequest;
+using IHttpResponse = MediaBrowser.Model.Services.IHttpResponse;
+using IResponse = MediaBrowser.Model.Services.IResponse;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public partial class WebSocketSharpRequest : IHttpRequest
+ {
+ private readonly HttpListenerRequest request;
+ private readonly IHttpResponse response;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ public WebSocketSharpRequest(HttpListenerContext httpContext, string operationName, ILogger logger, IMemoryStreamFactory memoryStreamProvider)
+ {
+ this.OperationName = operationName;
+ _memoryStreamProvider = memoryStreamProvider;
+ this.request = httpContext.Request;
+ this.response = new WebSocketSharpResponse(logger, httpContext.Response, this);
+ }
+
+ public HttpListenerRequest HttpRequest
+ {
+ get { return request; }
+ }
+
+ public object OriginalRequest
+ {
+ get { return request; }
+ }
+
+ public IResponse Response
+ {
+ get { return response; }
+ }
+
+ public IHttpResponse HttpResponse
+ {
+ get { return response; }
+ }
+
+ public string OperationName { get; set; }
+
+ public object Dto { get; set; }
+
+ public string RawUrl
+ {
+ get { return request.RawUrl; }
+ }
+
+ public string AbsoluteUri
+ {
+ get { return request.Url.AbsoluteUri.TrimEnd('/'); }
+ }
+
+ public string UserHostAddress
+ {
+ get { return request.UserHostAddress; }
+ }
+
+ public string XForwardedFor
+ {
+ get
+ {
+ return String.IsNullOrEmpty(request.Headers["X-Forwarded-For"]) ? null : request.Headers["X-Forwarded-For"];
+ }
+ }
+
+ public int? XForwardedPort
+ {
+ get
+ {
+ return string.IsNullOrEmpty(request.Headers["X-Forwarded-Port"]) ? (int?)null : int.Parse(request.Headers["X-Forwarded-Port"]);
+ }
+ }
+
+ public string XForwardedProtocol
+ {
+ get
+ {
+ return string.IsNullOrEmpty(request.Headers["X-Forwarded-Proto"]) ? null : request.Headers["X-Forwarded-Proto"];
+ }
+ }
+
+ public string XRealIp
+ {
+ get
+ {
+ return String.IsNullOrEmpty(request.Headers["X-Real-IP"]) ? null : request.Headers["X-Real-IP"];
+ }
+ }
+
+ private string remoteIp;
+ public string RemoteIp
+ {
+ get
+ {
+ return remoteIp ??
+ (remoteIp = (CheckBadChars(XForwardedFor)) ??
+ (NormalizeIp(CheckBadChars(XRealIp)) ??
+ (request.RemoteEndPoint != null ? NormalizeIp(request.RemoteEndPoint.IpAddress.ToString()) : null)));
+ }
+ }
+
+ private static readonly char[] HttpTrimCharacters = new char[] { (char)0x09, (char)0xA, (char)0xB, (char)0xC, (char)0xD, (char)0x20 };
+
+ //
+ // CheckBadChars - throws on invalid chars to be not found in header name/value
+ //
+ internal static string CheckBadChars(string name)
+ {
+ if (name == null || name.Length == 0)
+ {
+ return name;
+ }
+
+ // VALUE check
+ //Trim spaces from both ends
+ name = name.Trim(HttpTrimCharacters);
+
+ //First, check for correctly formed multi-line value
+ //Second, check for absenece of CTL characters
+ int crlf = 0;
+ for (int i = 0; i < name.Length; ++i)
+ {
+ char c = (char)(0x000000ff & (uint)name[i]);
+ switch (crlf)
+ {
+ case 0:
+ if (c == '\r')
+ {
+ crlf = 1;
+ }
+ else if (c == '\n')
+ {
+ // Technically this is bad HTTP. But it would be a breaking change to throw here.
+ // Is there an exploit?
+ crlf = 2;
+ }
+ else if (c == 127 || (c < ' ' && c != '\t'))
+ {
+ throw new ArgumentException("net_WebHeaderInvalidControlChars");
+ }
+ break;
+
+ case 1:
+ if (c == '\n')
+ {
+ crlf = 2;
+ break;
+ }
+ throw new ArgumentException("net_WebHeaderInvalidCRLFChars");
+
+ case 2:
+ if (c == ' ' || c == '\t')
+ {
+ crlf = 0;
+ break;
+ }
+ throw new ArgumentException("net_WebHeaderInvalidCRLFChars");
+ }
+ }
+ if (crlf != 0)
+ {
+ throw new ArgumentException("net_WebHeaderInvalidCRLFChars");
+ }
+ return name;
+ }
+
+ internal static bool ContainsNonAsciiChars(string token)
+ {
+ for (int i = 0; i < token.Length; ++i)
+ {
+ if ((token[i] < 0x20) || (token[i] > 0x7e))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private string NormalizeIp(string ip)
+ {
+ if (!string.IsNullOrWhiteSpace(ip))
+ {
+ // Handle ipv4 mapped to ipv6
+ const string srch = "::ffff:";
+ var index = ip.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
+ if (index == 0)
+ {
+ ip = ip.Substring(srch.Length);
+ }
+ }
+
+ return ip;
+ }
+
+ public bool IsSecureConnection
+ {
+ get { return request.IsSecureConnection || XForwardedProtocol == "https"; }
+ }
+
+ public string[] AcceptTypes
+ {
+ get { return request.AcceptTypes; }
+ }
+
+ private Dictionary<string, object> items;
+ public Dictionary<string, object> Items
+ {
+ get { return items ?? (items = new Dictionary<string, object>()); }
+ }
+
+ private string responseContentType;
+ public string ResponseContentType
+ {
+ get
+ {
+ return responseContentType
+ ?? (responseContentType = GetResponseContentType(this));
+ }
+ set
+ {
+ this.responseContentType = value;
+ HasExplicitResponseContentType = true;
+ }
+ }
+
+ public const string FormUrlEncoded = "application/x-www-form-urlencoded";
+ public const string MultiPartFormData = "multipart/form-data";
+ private static string GetResponseContentType(IRequest httpReq)
+ {
+ var specifiedContentType = GetQueryStringContentType(httpReq);
+ if (!string.IsNullOrEmpty(specifiedContentType)) return specifiedContentType;
+
+ var serverDefaultContentType = "application/json";
+
+ var acceptContentTypes = httpReq.AcceptTypes;
+ var defaultContentType = httpReq.ContentType;
+ if (HasAnyOfContentTypes(httpReq, FormUrlEncoded, MultiPartFormData))
+ {
+ defaultContentType = serverDefaultContentType;
+ }
+
+ var preferredContentTypes = new string[] {};
+
+ var acceptsAnything = false;
+ var hasDefaultContentType = !string.IsNullOrEmpty(defaultContentType);
+ if (acceptContentTypes != null)
+ {
+ var hasPreferredContentTypes = new bool[preferredContentTypes.Length];
+ foreach (var acceptsType in acceptContentTypes)
+ {
+ var contentType = HttpResultFactory.GetRealContentType(acceptsType);
+ acceptsAnything = acceptsAnything || contentType == "*/*";
+
+ for (var i = 0; i < preferredContentTypes.Length; i++)
+ {
+ if (hasPreferredContentTypes[i]) continue;
+ var preferredContentType = preferredContentTypes[i];
+ hasPreferredContentTypes[i] = contentType.StartsWith(preferredContentType);
+
+ //Prefer Request.ContentType if it is also a preferredContentType
+ if (hasPreferredContentTypes[i] && preferredContentType == defaultContentType)
+ return preferredContentType;
+ }
+ }
+
+ for (var i = 0; i < preferredContentTypes.Length; i++)
+ {
+ if (hasPreferredContentTypes[i]) return preferredContentTypes[i];
+ }
+
+ if (acceptsAnything)
+ {
+ if (hasDefaultContentType)
+ return defaultContentType;
+ if (serverDefaultContentType != null)
+ return serverDefaultContentType;
+ }
+ }
+
+ if (acceptContentTypes == null && httpReq.ContentType == Soap11)
+ {
+ return Soap11;
+ }
+
+ //We could also send a '406 Not Acceptable', but this is allowed also
+ return serverDefaultContentType;
+ }
+
+ public const string Soap11 = "text/xml; charset=utf-8";
+
+ public static bool HasAnyOfContentTypes(IRequest request, params string[] contentTypes)
+ {
+ if (contentTypes == null || request.ContentType == null) return false;
+ foreach (var contentType in contentTypes)
+ {
+ if (IsContentType(request, contentType)) return true;
+ }
+ return false;
+ }
+
+ public static bool IsContentType(IRequest request, string contentType)
+ {
+ return request.ContentType.StartsWith(contentType, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public const string Xml = "application/xml";
+ private static string GetQueryStringContentType(IRequest httpReq)
+ {
+ var format = httpReq.QueryString["format"];
+ if (format == null)
+ {
+ const int formatMaxLength = 4;
+ var pi = httpReq.PathInfo;
+ if (pi == null || pi.Length <= formatMaxLength) return null;
+ if (pi[0] == '/') pi = pi.Substring(1);
+ format = LeftPart(pi, '/');
+ if (format.Length > formatMaxLength) return null;
+ }
+
+ format = LeftPart(format, '.').ToLower();
+ if (format.Contains("json")) return "application/json";
+ if (format.Contains("xml")) return Xml;
+
+ return null;
+ }
+
+ public static string LeftPart(string strVal, char needle)
+ {
+ if (strVal == null) return null;
+ var pos = strVal.IndexOf(needle);
+ return pos == -1
+ ? strVal
+ : strVal.Substring(0, pos);
+ }
+
+ public bool HasExplicitResponseContentType { get; private set; }
+
+ public static string HandlerFactoryPath;
+
+ private string pathInfo;
+ public string PathInfo
+ {
+ get
+ {
+ if (this.pathInfo == null)
+ {
+ var mode = HandlerFactoryPath;
+
+ var pos = request.RawUrl.IndexOf("?");
+ if (pos != -1)
+ {
+ var path = request.RawUrl.Substring(0, pos);
+ this.pathInfo = GetPathInfo(
+ path,
+ mode,
+ mode ?? "");
+ }
+ else
+ {
+ this.pathInfo = request.RawUrl;
+ }
+
+ this.pathInfo = WebUtility.UrlDecode(pathInfo);
+ this.pathInfo = NormalizePathInfo(pathInfo, mode);
+ }
+ return this.pathInfo;
+ }
+ }
+
+ private static string GetPathInfo(string fullPath, string mode, string appPath)
+ {
+ var pathInfo = ResolvePathInfoFromMappedPath(fullPath, mode);
+ if (!string.IsNullOrEmpty(pathInfo)) return pathInfo;
+
+ //Wildcard mode relies on this to work out the handlerPath
+ pathInfo = ResolvePathInfoFromMappedPath(fullPath, appPath);
+ if (!string.IsNullOrEmpty(pathInfo)) return pathInfo;
+
+ return fullPath;
+ }
+
+
+
+ private static string ResolvePathInfoFromMappedPath(string fullPath, string mappedPathRoot)
+ {
+ if (mappedPathRoot == null) return null;
+
+ var sbPathInfo = new StringBuilder();
+ var fullPathParts = fullPath.Split('/');
+ var mappedPathRootParts = mappedPathRoot.Split('/');
+ var fullPathIndexOffset = mappedPathRootParts.Length - 1;
+ var pathRootFound = false;
+
+ for (var fullPathIndex = 0; fullPathIndex < fullPathParts.Length; fullPathIndex++)
+ {
+ if (pathRootFound)
+ {
+ sbPathInfo.Append("/" + fullPathParts[fullPathIndex]);
+ }
+ else if (fullPathIndex - fullPathIndexOffset >= 0)
+ {
+ pathRootFound = true;
+ for (var mappedPathRootIndex = 0; mappedPathRootIndex < mappedPathRootParts.Length; mappedPathRootIndex++)
+ {
+ if (!string.Equals(fullPathParts[fullPathIndex - fullPathIndexOffset + mappedPathRootIndex], mappedPathRootParts[mappedPathRootIndex], StringComparison.OrdinalIgnoreCase))
+ {
+ pathRootFound = false;
+ break;
+ }
+ }
+ }
+ }
+ if (!pathRootFound) return null;
+
+ var path = sbPathInfo.ToString();
+ return path.Length > 1 ? path.TrimEnd('/') : "/";
+ }
+
+ private Dictionary<string, System.Net.Cookie> cookies;
+ public IDictionary<string, System.Net.Cookie> Cookies
+ {
+ get
+ {
+ if (cookies == null)
+ {
+ cookies = new Dictionary<string, System.Net.Cookie>();
+ foreach (var cookie in this.request.Cookies)
+ {
+ var httpCookie = (Cookie) cookie;
+ cookies[httpCookie.Name] = new System.Net.Cookie(httpCookie.Name, httpCookie.Value, httpCookie.Path, httpCookie.Domain);
+ }
+ }
+
+ return cookies;
+ }
+ }
+
+ public string UserAgent
+ {
+ get { return request.UserAgent; }
+ }
+
+ public QueryParamCollection Headers
+ {
+ get { return request.Headers; }
+ }
+
+ private QueryParamCollection queryString;
+ public QueryParamCollection QueryString
+ {
+ get { return queryString ?? (queryString = MyHttpUtility.ParseQueryString(request.Url.Query)); }
+ }
+
+ private QueryParamCollection formData;
+ public QueryParamCollection FormData
+ {
+ get { return formData ?? (formData = this.Form); }
+ }
+
+ public bool IsLocal
+ {
+ get { return request.IsLocal; }
+ }
+
+ private string httpMethod;
+ public string HttpMethod
+ {
+ get
+ {
+ return httpMethod
+ ?? (httpMethod = request.HttpMethod);
+ }
+ }
+
+ public string Verb
+ {
+ get { return HttpMethod; }
+ }
+
+ public string Param(string name)
+ {
+ return Headers[name]
+ ?? QueryString[name]
+ ?? FormData[name];
+ }
+
+ public string ContentType
+ {
+ get { return request.ContentType; }
+ }
+
+ public Encoding contentEncoding;
+ public Encoding ContentEncoding
+ {
+ get { return contentEncoding ?? request.ContentEncoding; }
+ set { contentEncoding = value; }
+ }
+
+ public Uri UrlReferrer
+ {
+ get { return request.UrlReferrer; }
+ }
+
+ public static Encoding GetEncoding(string contentTypeHeader)
+ {
+ var param = GetParameter(contentTypeHeader, "charset=");
+ if (param == null) return null;
+ try
+ {
+ return Encoding.GetEncoding(param);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ public Stream InputStream
+ {
+ get { return request.InputStream; }
+ }
+
+ public long ContentLength
+ {
+ get { return request.ContentLength64; }
+ }
+
+ private IHttpFile[] httpFiles;
+ public IHttpFile[] Files
+ {
+ get
+ {
+ if (httpFiles == null)
+ {
+ if (files == null)
+ return httpFiles = new IHttpFile[0];
+
+ httpFiles = new IHttpFile[files.Count];
+ var i = 0;
+ foreach (var pair in files)
+ {
+ var reqFile = pair.Value;
+ httpFiles[i] = new HttpFile
+ {
+ ContentType = reqFile.ContentType,
+ ContentLength = reqFile.ContentLength,
+ FileName = reqFile.FileName,
+ InputStream = reqFile.InputStream,
+ };
+ i++;
+ }
+ }
+ return httpFiles;
+ }
+ }
+
+ static Stream GetSubStream(Stream stream, IMemoryStreamFactory streamProvider)
+ {
+ if (stream is MemoryStream)
+ {
+ var other = (MemoryStream)stream;
+
+ byte[] buffer;
+ if (streamProvider.TryGetBuffer(other, out buffer))
+ {
+ return streamProvider.CreateNew(buffer);
+ }
+ return streamProvider.CreateNew(other.ToArray());
+ }
+
+ return stream;
+ }
+
+ public static string GetHandlerPathIfAny(string listenerUrl)
+ {
+ if (listenerUrl == null) return null;
+ var pos = listenerUrl.IndexOf("://", StringComparison.OrdinalIgnoreCase);
+ if (pos == -1) return null;
+ var startHostUrl = listenerUrl.Substring(pos + "://".Length);
+ var endPos = startHostUrl.IndexOf('/');
+ if (endPos == -1) return null;
+ var endHostUrl = startHostUrl.Substring(endPos + 1);
+ return String.IsNullOrEmpty(endHostUrl) ? null : endHostUrl.TrimEnd('/');
+ }
+
+ public static string NormalizePathInfo(string pathInfo, string handlerPath)
+ {
+ if (handlerPath != null && pathInfo.TrimStart('/').StartsWith(
+ handlerPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return pathInfo.TrimStart('/').Substring(handlerPath.Length);
+ }
+
+ return pathInfo;
+ }
+ }
+
+ public class HttpFile : IHttpFile
+ {
+ public string Name { get; set; }
+ public string FileName { get; set; }
+ public long ContentLength { get; set; }
+ public string ContentType { get; set; }
+ public Stream InputStream { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpResponse.cs b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpResponse.cs
new file mode 100644
index 000000000..36f795411
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SocketSharp/WebSocketSharpResponse.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using MediaBrowser.Model.Logging;
+using SocketHttpListener.Net;
+using HttpListenerResponse = SocketHttpListener.Net.HttpListenerResponse;
+using IHttpResponse = MediaBrowser.Model.Services.IHttpResponse;
+using IRequest = MediaBrowser.Model.Services.IRequest;
+
+namespace Emby.Server.Implementations.HttpServer.SocketSharp
+{
+ public class WebSocketSharpResponse : IHttpResponse
+ {
+ private readonly ILogger _logger;
+ private readonly HttpListenerResponse _response;
+
+ public WebSocketSharpResponse(ILogger logger, HttpListenerResponse response, IRequest request)
+ {
+ _logger = logger;
+ this._response = response;
+ Items = new Dictionary<string, object>();
+ Request = request;
+ }
+
+ public IRequest Request { get; private set; }
+ public bool UseBufferedStream { get; set; }
+ public Dictionary<string, object> Items { get; private set; }
+ public object OriginalResponse
+ {
+ get { return _response; }
+ }
+
+ public int StatusCode
+ {
+ get { return this._response.StatusCode; }
+ set { this._response.StatusCode = value; }
+ }
+
+ public string StatusDescription
+ {
+ get { return this._response.StatusDescription; }
+ set { this._response.StatusDescription = value; }
+ }
+
+ public string ContentType
+ {
+ get { return _response.ContentType; }
+ set { _response.ContentType = value; }
+ }
+
+ //public ICookies Cookies { get; set; }
+
+ public void AddHeader(string name, string value)
+ {
+ if (string.Equals(name, "Content-Type", StringComparison.OrdinalIgnoreCase))
+ {
+ ContentType = value;
+ return;
+ }
+
+ _response.AddHeader(name, value);
+ }
+
+ public string GetHeader(string name)
+ {
+ return _response.Headers[name];
+ }
+
+ public void Redirect(string url)
+ {
+ _response.Redirect(url);
+ }
+
+ public Stream OutputStream
+ {
+ get { return _response.OutputStream; }
+ }
+
+ public void Close()
+ {
+ if (!this.IsClosed)
+ {
+ this.IsClosed = true;
+
+ try
+ {
+ CloseOutputStream(this._response);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing HttpListener output stream", ex);
+ }
+ }
+ }
+
+ public void CloseOutputStream(HttpListenerResponse response)
+ {
+ try
+ {
+ var outputStream = response.OutputStream;
+
+ // This is needed with compression
+ if (outputStream is ResponseStream)
+ {
+ //if (!string.IsNullOrWhiteSpace(GetHeader("Content-Encoding")))
+ {
+ outputStream.Flush();
+ }
+
+ outputStream.Dispose();
+ }
+ response.Close();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in HttpListenerResponseWrapper: " + ex.Message, ex);
+ }
+ }
+
+ public bool IsClosed
+ {
+ get;
+ private set;
+ }
+
+ public void SetContentLength(long contentLength)
+ {
+ //you can happily set the Content-Length header in Asp.Net
+ //but HttpListener will complain if you do - you have to set ContentLength64 on the response.
+ //workaround: HttpListener throws "The parameter is incorrect" exceptions when we try to set the Content-Length header
+ _response.ContentLength64 = contentLength;
+ }
+
+ public void SetCookie(Cookie cookie)
+ {
+ var cookieStr = AsHeaderValue(cookie);
+ _response.Headers.Add("Set-Cookie", cookieStr);
+ }
+
+ public static string AsHeaderValue(Cookie cookie)
+ {
+ var defaultExpires = DateTime.MinValue;
+
+ var path = cookie.Expires == defaultExpires
+ ? "/"
+ : cookie.Path ?? "/";
+
+ var sb = new StringBuilder();
+
+ sb.Append($"{cookie.Name}={cookie.Value};path={path}");
+
+ if (cookie.Expires != defaultExpires)
+ {
+ sb.Append($";expires={cookie.Expires:R}");
+ }
+
+ if (!string.IsNullOrEmpty(cookie.Domain))
+ {
+ sb.Append($";domain={cookie.Domain}");
+ }
+ //else if (restrictAllCookiesToDomain != null)
+ //{
+ // sb.Append($";domain={restrictAllCookiesToDomain}");
+ //}
+
+ if (cookie.Secure)
+ {
+ sb.Append(";Secure");
+ }
+ if (cookie.HttpOnly)
+ {
+ sb.Append(";HttpOnly");
+ }
+
+ return sb.ToString();
+ }
+
+
+ public bool SendChunked
+ {
+ get { return _response.SendChunked; }
+ set { _response.SendChunked = value; }
+ }
+
+ public bool KeepAlive { get; set; }
+
+ public void ClearCookies()
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/StreamWriter.cs b/Emby.Server.Implementations/HttpServer/StreamWriter.cs
new file mode 100644
index 000000000..33378949c
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/StreamWriter.cs
@@ -0,0 +1,129 @@
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ /// <summary>
+ /// Class StreamWriter
+ /// </summary>
+ public class StreamWriter : IAsyncStreamWriter, IHasHeaders
+ {
+ private ILogger Logger { get; set; }
+
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+ /// <summary>
+ /// Gets or sets the source stream.
+ /// </summary>
+ /// <value>The source stream.</value>
+ private Stream SourceStream { get; set; }
+
+ private byte[] SourceBytes { get; set; }
+
+ /// <summary>
+ /// The _options
+ /// </summary>
+ private readonly IDictionary<string, string> _options = new Dictionary<string, string>();
+ /// <summary>
+ /// Gets the options.
+ /// </summary>
+ /// <value>The options.</value>
+ public IDictionary<string, string> Headers
+ {
+ get { return _options; }
+ }
+
+ public Action OnComplete { get; set; }
+ public Action OnError { get; set; }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StreamWriter" /> class.
+ /// </summary>
+ /// <param name="source">The source.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <param name="logger">The logger.</param>
+ public StreamWriter(Stream source, string contentType, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(contentType))
+ {
+ throw new ArgumentNullException("contentType");
+ }
+
+ SourceStream = source;
+ Logger = logger;
+
+ Headers["Content-Type"] = contentType;
+
+ if (source.CanSeek)
+ {
+ Headers["Content-Length"] = source.Length.ToString(UsCulture);
+ }
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StreamWriter"/> class.
+ /// </summary>
+ /// <param name="source">The source.</param>
+ /// <param name="contentType">Type of the content.</param>
+ /// <param name="logger">The logger.</param>
+ public StreamWriter(byte[] source, string contentType, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(contentType))
+ {
+ throw new ArgumentNullException("contentType");
+ }
+
+ SourceBytes = source;
+ Logger = logger;
+
+ Headers["Content-Type"] = contentType;
+
+ Headers["Content-Length"] = source.Length.ToString(UsCulture);
+ }
+
+ public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var bytes = SourceBytes;
+
+ if (bytes != null)
+ {
+ await responseStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
+ }
+ else
+ {
+ using (var src = SourceStream)
+ {
+ await src.CopyToAsync(responseStream).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error streaming data", ex);
+
+ if (OnError != null)
+ {
+ OnError();
+ }
+
+ throw;
+ }
+ finally
+ {
+ if (OnComplete != null)
+ {
+ OnComplete();
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/HttpServer/SwaggerService.cs b/Emby.Server.Implementations/HttpServer/SwaggerService.cs
new file mode 100644
index 000000000..d41946645
--- /dev/null
+++ b/Emby.Server.Implementations/HttpServer/SwaggerService.cs
@@ -0,0 +1,47 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Net;
+using System.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.HttpServer
+{
+ public class SwaggerService : IService, IRequiresRequest
+ {
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+
+ public SwaggerService(IServerApplicationPaths appPaths, IFileSystem fileSystem, IHttpResultFactory resultFactory)
+ {
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ _resultFactory = resultFactory;
+ }
+
+ /// <summary>
+ /// Gets the specified request.
+ /// </summary>
+ /// <param name="request">The request.</param>
+ /// <returns>System.Object.</returns>
+ public object Get(GetSwaggerResource request)
+ {
+ var swaggerDirectory = Path.Combine(_appPaths.ApplicationResourcesPath, "swagger-ui");
+
+ var requestedFile = Path.Combine(swaggerDirectory, request.ResourceName.Replace('/', _fileSystem.DirectorySeparatorChar));
+
+ return _resultFactory.GetStaticFileResult(Request, requestedFile).Result;
+ }
+
+ /// <summary>
+ /// Gets or sets the result factory.
+ /// </summary>
+ /// <value>The result factory.</value>
+ private readonly IHttpResultFactory _resultFactory;
+
+ /// <summary>
+ /// Gets or sets the request context.
+ /// </summary>
+ /// <value>The request context.</value>
+ public IRequest Request { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs
new file mode 100644
index 000000000..39033249f
--- /dev/null
+++ b/Emby.Server.Implementations/IO/FileRefresher.cs
@@ -0,0 +1,326 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.System;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.IO
+{
+ public class FileRefresher : IDisposable
+ {
+ private ILogger Logger { get; set; }
+ private ITaskManager TaskManager { get; set; }
+ private ILibraryManager LibraryManager { get; set; }
+ private IServerConfigurationManager ConfigurationManager { get; set; }
+ private readonly IFileSystem _fileSystem;
+ private readonly List<string> _affectedPaths = new List<string>();
+ private ITimer _timer;
+ private readonly ITimerFactory _timerFactory;
+ private readonly object _timerLock = new object();
+ public string Path { get; private set; }
+
+ public event EventHandler<EventArgs> Completed;
+ private readonly IEnvironmentInfo _environmentInfo;
+
+ public FileRefresher(string path, IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ITaskManager taskManager, ILogger logger, ITimerFactory timerFactory, IEnvironmentInfo environmentInfo)
+ {
+ logger.Debug("New file refresher created for {0}", path);
+ Path = path;
+
+ _fileSystem = fileSystem;
+ ConfigurationManager = configurationManager;
+ LibraryManager = libraryManager;
+ TaskManager = taskManager;
+ Logger = logger;
+ _timerFactory = timerFactory;
+ _environmentInfo = environmentInfo;
+ AddPath(path);
+ }
+
+ private void AddAffectedPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ if (!_affectedPaths.Contains(path, StringComparer.Ordinal))
+ {
+ _affectedPaths.Add(path);
+ }
+ }
+
+ public void AddPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ lock (_timerLock)
+ {
+ AddAffectedPath(path);
+ }
+ RestartTimer();
+ }
+
+ public void RestartTimer()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ lock (_timerLock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_timer == null)
+ {
+ _timer = _timerFactory.Create(OnTimerCallback, null, TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ _timer.Change(TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ }
+
+ public void ResetPath(string path, string affectedFile)
+ {
+ lock (_timerLock)
+ {
+ Logger.Debug("Resetting file refresher from {0} to {1}", Path, path);
+
+ Path = path;
+ AddAffectedPath(path);
+
+ if (!string.IsNullOrWhiteSpace(affectedFile))
+ {
+ AddAffectedPath(affectedFile);
+ }
+ }
+ RestartTimer();
+ }
+
+ private async void OnTimerCallback(object state)
+ {
+ List<string> paths;
+
+ lock (_timerLock)
+ {
+ paths = _affectedPaths.ToList();
+ }
+
+ // Extend the timer as long as any of the paths are still being written to.
+ if (paths.Any(IsFileLocked))
+ {
+ Logger.Info("Timer extended.");
+ RestartTimer();
+ return;
+ }
+
+ Logger.Debug("Timer stopped.");
+
+ DisposeTimer();
+ EventHelper.FireEventIfNotNull(Completed, this, EventArgs.Empty, Logger);
+
+ try
+ {
+ await ProcessPathChanges(paths.ToList()).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error processing directory changes", ex);
+ }
+ }
+
+ private async Task ProcessPathChanges(List<string> paths)
+ {
+ var itemsToRefresh = paths
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(GetAffectedBaseItem)
+ .Where(item => item != null)
+ .DistinctBy(i => i.Id)
+ .ToList();
+
+ foreach (var p in paths)
+ {
+ Logger.Info(p + " reports change.");
+ }
+
+ // If the root folder changed, run the library task so the user can see it
+ if (itemsToRefresh.Any(i => i is AggregateFolder))
+ {
+ LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
+ return;
+ }
+
+ foreach (var item in itemsToRefresh)
+ {
+ Logger.Info(item.Name + " (" + item.Path + ") will be refreshed.");
+
+ try
+ {
+ await item.ChangedExternally().ConfigureAwait(false);
+ }
+ catch (IOException ex)
+ {
+ // For now swallow and log.
+ // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable)
+ // Should we remove it from it's parent?
+ Logger.ErrorException("Error refreshing {0}", ex, item.Name);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error refreshing {0}", ex, item.Name);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the affected base item.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns>BaseItem.</returns>
+ private BaseItem GetAffectedBaseItem(string path)
+ {
+ BaseItem item = null;
+
+ while (item == null && !string.IsNullOrEmpty(path))
+ {
+ item = LibraryManager.FindByPath(path, null);
+
+ path = System.IO.Path.GetDirectoryName(path);
+ }
+
+ if (item != null)
+ {
+ // If the item has been deleted find the first valid parent that still exists
+ while (!_fileSystem.DirectoryExists(item.Path) && !_fileSystem.FileExists(item.Path))
+ {
+ item = item.GetParent();
+
+ if (item == null)
+ {
+ break;
+ }
+ }
+ }
+
+ return item;
+ }
+
+ private bool IsFileLocked(string path)
+ {
+ if (_environmentInfo.OperatingSystem != OperatingSystem.Windows)
+ {
+ // Causing lockups on linux
+ return false;
+ }
+
+ try
+ {
+ var data = _fileSystem.GetFileSystemInfo(path);
+
+ if (!data.Exists
+ || data.IsDirectory
+
+ // Opening a writable stream will fail with readonly files
+ || data.IsReadOnly)
+ {
+ return false;
+ }
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error getting file system info for: {0}", ex, path);
+ return false;
+ }
+
+ // In order to determine if the file is being written to, we have to request write access
+ // But if the server only has readonly access, this is going to cause this entire algorithm to fail
+ // So we'll take a best guess about our access level
+ var requestedFileAccess = ConfigurationManager.Configuration.SaveLocalMeta
+ ? FileAccessMode.ReadWrite
+ : FileAccessMode.Read;
+
+ try
+ {
+ using (_fileSystem.GetFileStream(path, FileOpenMode.Open, requestedFileAccess, FileShareMode.ReadWrite))
+ {
+ //file is not locked
+ return false;
+ }
+ }
+ //catch (DirectoryNotFoundException)
+ //{
+ // // File may have been deleted
+ // return false;
+ //}
+ catch (FileNotFoundException)
+ {
+ // File may have been deleted
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ Logger.Debug("No write permission for: {0}.", path);
+ return false;
+ }
+ catch (IOException)
+ {
+ //the file is unavailable because it is:
+ //still being written to
+ //or being processed by another thread
+ //or does not exist (has already been processed)
+ Logger.Debug("{0} is locked.", path);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error determining if file is locked: {0}", ex, path);
+ return false;
+ }
+ }
+
+ private void DisposeTimer()
+ {
+ lock (_timerLock)
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+
+ private bool _disposed;
+ public void Dispose()
+ {
+ _disposed = true;
+ DisposeTimer();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs
new file mode 100644
index 000000000..0b1391ae0
--- /dev/null
+++ b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs
@@ -0,0 +1,55 @@
+using System;
+using System.IO;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.IO
+{
+ public class MbLinkShortcutHandler : IShortcutHandler
+ {
+ private readonly IFileSystem _fileSystem;
+
+ public MbLinkShortcutHandler(IFileSystem fileSystem)
+ {
+ _fileSystem = fileSystem;
+ }
+
+ public string Extension
+ {
+ get { return ".mblink"; }
+ }
+
+ public string Resolve(string shortcutPath)
+ {
+ if (string.IsNullOrEmpty(shortcutPath))
+ {
+ throw new ArgumentNullException("filenshortcutPathame");
+ }
+
+ if (string.Equals(Path.GetExtension(shortcutPath), ".mblink", StringComparison.OrdinalIgnoreCase))
+ {
+ var path = _fileSystem.ReadAllText(shortcutPath);
+
+ return _fileSystem.NormalizePath(path);
+ }
+
+ return null;
+ }
+
+ public void Create(string shortcutPath, string targetPath)
+ {
+ if (string.IsNullOrEmpty(shortcutPath))
+ {
+ throw new ArgumentNullException("shortcutPath");
+ }
+
+ if (string.IsNullOrEmpty(targetPath))
+ {
+ throw new ArgumentNullException("targetPath");
+ }
+
+ _fileSystem.WriteAllText(shortcutPath, targetPath);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/IO/ThrottledStream.cs b/Emby.Server.Implementations/IO/ThrottledStream.cs
new file mode 100644
index 000000000..81760b639
--- /dev/null
+++ b/Emby.Server.Implementations/IO/ThrottledStream.cs
@@ -0,0 +1,394 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.IO
+{
+ /// <summary>
+ /// Class for streaming data with throttling support.
+ /// </summary>
+ public class ThrottledStream : Stream
+ {
+ /// <summary>
+ /// A constant used to specify an infinite number of bytes that can be transferred per second.
+ /// </summary>
+ public const long Infinite = 0;
+
+ #region Private members
+ /// <summary>
+ /// The base stream.
+ /// </summary>
+ private readonly Stream _baseStream;
+
+ /// <summary>
+ /// The maximum bytes per second that can be transferred through the base stream.
+ /// </summary>
+ private long _maximumBytesPerSecond;
+
+ /// <summary>
+ /// The number of bytes that has been transferred since the last throttle.
+ /// </summary>
+ private long _byteCount;
+
+ /// <summary>
+ /// The start time in milliseconds of the last throttle.
+ /// </summary>
+ private long _start;
+ #endregion
+
+ #region Properties
+ /// <summary>
+ /// Gets the current milliseconds.
+ /// </summary>
+ /// <value>The current milliseconds.</value>
+ protected long CurrentMilliseconds
+ {
+ get
+ {
+ return Environment.TickCount;
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the maximum bytes per second that can be transferred through the base stream.
+ /// </summary>
+ /// <value>The maximum bytes per second.</value>
+ public long MaximumBytesPerSecond
+ {
+ get
+ {
+ return _maximumBytesPerSecond;
+ }
+ set
+ {
+ if (MaximumBytesPerSecond != value)
+ {
+ _maximumBytesPerSecond = value;
+ Reset();
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the current stream supports reading.
+ /// </summary>
+ /// <returns>true if the stream supports reading; otherwise, false.</returns>
+ public override bool CanRead
+ {
+ get
+ {
+ return _baseStream.CanRead;
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the current stream supports seeking.
+ /// </summary>
+ /// <value></value>
+ /// <returns>true if the stream supports seeking; otherwise, false.</returns>
+ public override bool CanSeek
+ {
+ get
+ {
+ return _baseStream.CanSeek;
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the current stream supports writing.
+ /// </summary>
+ /// <value></value>
+ /// <returns>true if the stream supports writing; otherwise, false.</returns>
+ public override bool CanWrite
+ {
+ get
+ {
+ return _baseStream.CanWrite;
+ }
+ }
+
+ /// <summary>
+ /// Gets the length in bytes of the stream.
+ /// </summary>
+ /// <value></value>
+ /// <returns>A long value representing the length of the stream in bytes.</returns>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ public override long Length
+ {
+ get
+ {
+ return _baseStream.Length;
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the position within the current stream.
+ /// </summary>
+ /// <value></value>
+ /// <returns>The current position within the stream.</returns>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ public override long Position
+ {
+ get
+ {
+ return _baseStream.Position;
+ }
+ set
+ {
+ _baseStream.Position = value;
+ }
+ }
+ #endregion
+
+ public long MinThrottlePosition;
+
+ #region Ctor
+ /// <summary>
+ /// Initializes a new instance of the <see cref="T:ThrottledStream"/> class.
+ /// </summary>
+ /// <param name="baseStream">The base stream.</param>
+ /// <param name="maximumBytesPerSecond">The maximum bytes per second that can be transferred through the base stream.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <see cref="baseStream"/> is a null reference.</exception>
+ /// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="maximumBytesPerSecond"/> is a negative value.</exception>
+ public ThrottledStream(Stream baseStream, long maximumBytesPerSecond)
+ {
+ if (baseStream == null)
+ {
+ throw new ArgumentNullException("baseStream");
+ }
+
+ if (maximumBytesPerSecond < 0)
+ {
+ throw new ArgumentOutOfRangeException("maximumBytesPerSecond",
+ maximumBytesPerSecond, "The maximum number of bytes per second can't be negative.");
+ }
+
+ _baseStream = baseStream;
+ _maximumBytesPerSecond = maximumBytesPerSecond;
+ _start = CurrentMilliseconds;
+ _byteCount = 0;
+ }
+ #endregion
+
+ #region Public methods
+ /// <summary>
+ /// Clears all buffers for this stream and causes any buffered data to be written to the underlying device.
+ /// </summary>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs.</exception>
+ public override void Flush()
+ {
+ _baseStream.Flush();
+ }
+
+ /// <summary>
+ /// Reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read.
+ /// </summary>
+ /// <param name="buffer">An array of bytes. When this method returns, the buffer contains the specified byte array with the values between offset and (offset + count - 1) replaced by the bytes read from the current source.</param>
+ /// <param name="offset">The zero-based byte offset in buffer at which to begin storing the data read from the current stream.</param>
+ /// <param name="count">The maximum number of bytes to be read from the current stream.</param>
+ /// <returns>
+ /// The total number of bytes read into the buffer. This can be less than the number of bytes requested if that many bytes are not currently available, or zero (0) if the end of the stream has been reached.
+ /// </returns>
+ /// <exception cref="T:System.ArgumentException">The sum of offset and count is larger than the buffer length. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support reading. </exception>
+ /// <exception cref="T:System.ArgumentNullException">buffer is null. </exception>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception>
+ /// <exception cref="T:System.ArgumentOutOfRangeException">offset or count is negative. </exception>
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ Throttle(count);
+
+ return _baseStream.Read(buffer, offset, count);
+ }
+
+ /// <summary>
+ /// Sets the position within the current stream.
+ /// </summary>
+ /// <param name="offset">A byte offset relative to the origin parameter.</param>
+ /// <param name="origin">A value of type <see cref="T:System.IO.SeekOrigin"></see> indicating the reference point used to obtain the new position.</param>
+ /// <returns>
+ /// The new position within the current stream.
+ /// </returns>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking, such as if the stream is constructed from a pipe or console output. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ return _baseStream.Seek(offset, origin);
+ }
+
+ /// <summary>
+ /// Sets the length of the current stream.
+ /// </summary>
+ /// <param name="value">The desired length of the current stream in bytes.</param>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support both writing and seeking, such as if the stream is constructed from a pipe or console output. </exception>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ public override void SetLength(long value)
+ {
+ _baseStream.SetLength(value);
+ }
+
+ private long _bytesWritten;
+
+ /// <summary>
+ /// Writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written.
+ /// </summary>
+ /// <param name="buffer">An array of bytes. This method copies count bytes from buffer to the current stream.</param>
+ /// <param name="offset">The zero-based byte offset in buffer at which to begin copying bytes to the current stream.</param>
+ /// <param name="count">The number of bytes to be written to the current stream.</param>
+ /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception>
+ /// <exception cref="T:System.NotSupportedException">The base stream does not support writing. </exception>
+ /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception>
+ /// <exception cref="T:System.ArgumentNullException">buffer is null. </exception>
+ /// <exception cref="T:System.ArgumentException">The sum of offset and count is greater than the buffer length. </exception>
+ /// <exception cref="T:System.ArgumentOutOfRangeException">offset or count is negative. </exception>
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ Throttle(count);
+
+ _baseStream.Write(buffer, offset, count);
+
+ _bytesWritten += count;
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ await ThrottleAsync(count, cancellationToken).ConfigureAwait(false);
+
+ await _baseStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
+
+ _bytesWritten += count;
+ }
+
+ /// <summary>
+ /// Returns a <see cref="T:System.String"></see> that represents the current <see cref="T:System.Object"></see>.
+ /// </summary>
+ /// <returns>
+ /// A <see cref="T:System.String"></see> that represents the current <see cref="T:System.Object"></see>.
+ /// </returns>
+ public override string ToString()
+ {
+ return _baseStream.ToString();
+ }
+ #endregion
+
+ private bool ThrottleCheck(int bufferSizeInBytes)
+ {
+ if (_bytesWritten < MinThrottlePosition)
+ {
+ return false;
+ }
+
+ // Make sure the buffer isn't empty.
+ if (_maximumBytesPerSecond <= 0 || bufferSizeInBytes <= 0)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ #region Protected methods
+ /// <summary>
+ /// Throttles for the specified buffer size in bytes.
+ /// </summary>
+ /// <param name="bufferSizeInBytes">The buffer size in bytes.</param>
+ protected void Throttle(int bufferSizeInBytes)
+ {
+ if (!ThrottleCheck(bufferSizeInBytes))
+ {
+ return ;
+ }
+
+ _byteCount += bufferSizeInBytes;
+ long elapsedMilliseconds = CurrentMilliseconds - _start;
+
+ if (elapsedMilliseconds > 0)
+ {
+ // Calculate the current bps.
+ long bps = _byteCount * 1000L / elapsedMilliseconds;
+
+ // If the bps are more then the maximum bps, try to throttle.
+ if (bps > _maximumBytesPerSecond)
+ {
+ // Calculate the time to sleep.
+ long wakeElapsed = _byteCount * 1000L / _maximumBytesPerSecond;
+ int toSleep = (int)(wakeElapsed - elapsedMilliseconds);
+
+ if (toSleep > 1)
+ {
+ try
+ {
+ // The time to sleep is more then a millisecond, so sleep.
+ var task = Task.Delay(toSleep);
+ Task.WaitAll(task);
+ }
+ catch
+ {
+ // Eatup ThreadAbortException.
+ }
+
+ // A sleep has been done, reset.
+ Reset();
+ }
+ }
+ }
+ }
+
+ protected async Task ThrottleAsync(int bufferSizeInBytes, CancellationToken cancellationToken)
+ {
+ if (!ThrottleCheck(bufferSizeInBytes))
+ {
+ return;
+ }
+
+ _byteCount += bufferSizeInBytes;
+ long elapsedMilliseconds = CurrentMilliseconds - _start;
+
+ if (elapsedMilliseconds > 0)
+ {
+ // Calculate the current bps.
+ long bps = _byteCount * 1000L / elapsedMilliseconds;
+
+ // If the bps are more then the maximum bps, try to throttle.
+ if (bps > _maximumBytesPerSecond)
+ {
+ // Calculate the time to sleep.
+ long wakeElapsed = _byteCount * 1000L / _maximumBytesPerSecond;
+ int toSleep = (int)(wakeElapsed - elapsedMilliseconds);
+
+ if (toSleep > 1)
+ {
+ // The time to sleep is more then a millisecond, so sleep.
+ await Task.Delay(toSleep, cancellationToken).ConfigureAwait(false);
+
+ // A sleep has been done, reset.
+ Reset();
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Will reset the bytecount to 0 and reset the start time to the current time.
+ /// </summary>
+ protected void Reset()
+ {
+ long difference = CurrentMilliseconds - _start;
+
+ // Only reset counters when a known history is available of more then 1 second.
+ if (difference > 1000)
+ {
+ _byteCount = 0;
+ _start = CurrentMilliseconds;
+ }
+ }
+ #endregion
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
new file mode 100644
index 000000000..7a36691df
--- /dev/null
+++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
@@ -0,0 +1,369 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.Images
+{
+ public abstract class BaseDynamicImageProvider<T> : IHasItemChangeMonitor, IForcedProvider, ICustomMetadataProvider<T>, IHasOrder
+ where T : IHasMetadata
+ {
+ protected IFileSystem FileSystem { get; private set; }
+ protected IProviderManager ProviderManager { get; private set; }
+ protected IApplicationPaths ApplicationPaths { get; private set; }
+ protected IImageProcessor ImageProcessor { get; set; }
+
+ protected BaseDynamicImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor)
+ {
+ ApplicationPaths = applicationPaths;
+ ProviderManager = providerManager;
+ FileSystem = fileSystem;
+ ImageProcessor = imageProcessor;
+ }
+
+ protected virtual bool Supports(IHasImages item)
+ {
+ return true;
+ }
+
+ public virtual IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new List<ImageType>
+ {
+ ImageType.Primary,
+ ImageType.Thumb
+ };
+ }
+
+ private IEnumerable<ImageType> GetEnabledImages(IHasImages item)
+ {
+ //var options = ProviderManager.GetMetadataOptions(item);
+
+ return GetSupportedImages(item);
+ //return GetSupportedImages(item).Where(i => IsEnabled(options, i, item)).ToList();
+ }
+
+ private bool IsEnabled(MetadataOptions options, ImageType type, IHasImages item)
+ {
+ if (type == ImageType.Backdrop)
+ {
+ if (item.LockedFields.Contains(MetadataFields.Backdrops))
+ {
+ return false;
+ }
+ }
+ else if (type == ImageType.Screenshot)
+ {
+ if (item.LockedFields.Contains(MetadataFields.Screenshots))
+ {
+ return false;
+ }
+ }
+ else
+ {
+ if (item.LockedFields.Contains(MetadataFields.Images))
+ {
+ return false;
+ }
+ }
+
+ return options.IsEnabled(type);
+ }
+
+ public async Task<ItemUpdateType> FetchAsync(T item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ {
+ if (!Supports(item))
+ {
+ return ItemUpdateType.None;
+ }
+
+ var updateType = ItemUpdateType.None;
+ var supportedImages = GetEnabledImages(item).ToList();
+
+ if (supportedImages.Contains(ImageType.Primary))
+ {
+ var primaryResult = await FetchAsync(item, ImageType.Primary, options, cancellationToken).ConfigureAwait(false);
+ updateType = updateType | primaryResult;
+ }
+
+ if (supportedImages.Contains(ImageType.Thumb))
+ {
+ var thumbResult = await FetchAsync(item, ImageType.Thumb, options, cancellationToken).ConfigureAwait(false);
+ updateType = updateType | thumbResult;
+ }
+
+ return updateType;
+ }
+
+ protected async Task<ItemUpdateType> FetchAsync(IHasImages item, ImageType imageType, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ {
+ var image = item.GetImageInfo(imageType, 0);
+
+ if (image != null)
+ {
+ if (!image.IsLocalFile)
+ {
+ return ItemUpdateType.None;
+ }
+
+ if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
+ {
+ return ItemUpdateType.None;
+ }
+ }
+
+ var items = await GetItemsWithImages(item).ConfigureAwait(false);
+
+ return await FetchToFileInternal(item, items, imageType, cancellationToken).ConfigureAwait(false);
+ }
+
+ protected async Task<ItemUpdateType> FetchToFileInternal(IHasImages item,
+ List<BaseItem> itemsWithImages,
+ ImageType imageType,
+ CancellationToken cancellationToken)
+ {
+ var outputPathWithoutExtension = Path.Combine(ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
+ FileSystem.CreateDirectory(Path.GetDirectoryName(outputPathWithoutExtension));
+ string outputPath = await CreateImage(item, itemsWithImages, outputPathWithoutExtension, imageType, 0).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(outputPath))
+ {
+ return ItemUpdateType.None;
+ }
+
+ var mimeType = MimeTypes.GetMimeType(outputPath);
+
+ if (string.Equals(mimeType, "application/octet-stream", StringComparison.OrdinalIgnoreCase))
+ {
+ mimeType = "image/png";
+ }
+
+ await ProviderManager.SaveImage(item, outputPath, mimeType, imageType, null, false, cancellationToken).ConfigureAwait(false);
+
+ return ItemUpdateType.ImageUpdate;
+ }
+
+ protected abstract Task<List<BaseItem>> GetItemsWithImages(IHasImages item);
+
+ protected Task<string> CreateThumbCollage(IHasImages primaryItem, List<BaseItem> items, string outputPath)
+ {
+ return CreateCollage(primaryItem, items, outputPath, 640, 360);
+ }
+
+ protected virtual IEnumerable<string> GetStripCollageImagePaths(IHasImages primaryItem, IEnumerable<BaseItem> items)
+ {
+ return items
+ .Select(i =>
+ {
+ var image = i.GetImageInfo(ImageType.Primary, 0);
+
+ if (image != null && image.IsLocalFile)
+ {
+ return image.Path;
+ }
+ image = i.GetImageInfo(ImageType.Thumb, 0);
+
+ if (image != null && image.IsLocalFile)
+ {
+ return image.Path;
+ }
+ return null;
+ })
+ .Where(i => !string.IsNullOrWhiteSpace(i));
+ }
+
+ protected Task<string> CreatePosterCollage(IHasImages primaryItem, List<BaseItem> items, string outputPath)
+ {
+ return CreateCollage(primaryItem, items, outputPath, 400, 600);
+ }
+
+ protected Task<string> CreateSquareCollage(IHasImages primaryItem, List<BaseItem> items, string outputPath)
+ {
+ return CreateCollage(primaryItem, items, outputPath, 600, 600);
+ }
+
+ protected Task<string> CreateThumbCollage(IHasImages primaryItem, List<BaseItem> items, string outputPath, int width, int height)
+ {
+ return CreateCollage(primaryItem, items, outputPath, width, height);
+ }
+
+ private async Task<string> CreateCollage(IHasImages primaryItem, List<BaseItem> items, string outputPath, int width, int height)
+ {
+ FileSystem.CreateDirectory(Path.GetDirectoryName(outputPath));
+
+ var options = new ImageCollageOptions
+ {
+ Height = height,
+ Width = width,
+ OutputPath = outputPath,
+ InputPaths = GetStripCollageImagePaths(primaryItem, items).ToArray()
+ };
+
+ if (options.InputPaths.Length == 0)
+ {
+ return null;
+ }
+
+ if (!ImageProcessor.SupportsImageCollageCreation)
+ {
+ return null;
+ }
+
+ await ImageProcessor.CreateImageCollage(options).ConfigureAwait(false);
+ return outputPath;
+ }
+
+ public string Name
+ {
+ get { return "Dynamic Image Provider"; }
+ }
+
+ protected virtual async Task<string> CreateImage(IHasImages item,
+ List<BaseItem> itemsWithImages,
+ string outputPathWithoutExtension,
+ ImageType imageType,
+ int imageIndex)
+ {
+ if (itemsWithImages.Count == 0)
+ {
+ return null;
+ }
+
+ string outputPath = Path.ChangeExtension(outputPathWithoutExtension, ".png");
+
+ if (imageType == ImageType.Thumb)
+ {
+ return await CreateThumbCollage(item, itemsWithImages, outputPath).ConfigureAwait(false);
+ }
+
+ if (imageType == ImageType.Primary)
+ {
+ if (item is UserView)
+ {
+ return await CreateSquareCollage(item, itemsWithImages, outputPath).ConfigureAwait(false);
+ }
+ if (item is Playlist || item is MusicGenre)
+ {
+ return await CreateSquareCollage(item, itemsWithImages, outputPath).ConfigureAwait(false);
+ }
+ return await CreatePosterCollage(item, itemsWithImages, outputPath).ConfigureAwait(false);
+ }
+
+ throw new ArgumentException("Unexpected image type");
+ }
+
+ protected virtual int MaxImageAgeDays
+ {
+ get { return 7; }
+ }
+
+ public bool HasChanged(IHasMetadata item, IDirectoryService directoryServicee)
+ {
+ if (!Supports(item))
+ {
+ return false;
+ }
+
+ var supportedImages = GetEnabledImages(item).ToList();
+
+ if (supportedImages.Contains(ImageType.Primary) && HasChanged(item, ImageType.Primary))
+ {
+ return true;
+ }
+ if (supportedImages.Contains(ImageType.Thumb) && HasChanged(item, ImageType.Thumb))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ protected bool HasChanged(IHasImages item, ImageType type)
+ {
+ var image = item.GetImageInfo(type, 0);
+
+ if (image != null)
+ {
+ if (!image.IsLocalFile)
+ {
+ return false;
+ }
+
+ if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
+ {
+ return false;
+ }
+
+ var age = DateTime.UtcNow - image.DateModified;
+ if (age.TotalDays <= MaxImageAgeDays)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected List<BaseItem> GetFinalItems(List<BaseItem> items)
+ {
+ return GetFinalItems(items, 4);
+ }
+
+ protected virtual List<BaseItem> GetFinalItems(List<BaseItem> items, int limit)
+ {
+ // Rotate the images once every x days
+ var random = DateTime.Now.DayOfYear % MaxImageAgeDays;
+
+ return items
+ .OrderBy(i => (random + string.Empty + items.IndexOf(i)).GetMD5())
+ .Take(limit)
+ .OrderBy(i => i.Name)
+ .ToList();
+ }
+
+ public int Order
+ {
+ get
+ {
+ // Run before the default image provider which will download placeholders
+ return 0;
+ }
+ }
+
+ protected async Task<string> CreateSingleImage(List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType)
+ {
+ var image = itemsWithImages
+ .Where(i => i.HasImage(imageType) && i.GetImageInfo(imageType, 0).IsLocalFile && Path.HasExtension(i.GetImagePath(imageType)))
+ .Select(i => i.GetImagePath(imageType))
+ .FirstOrDefault();
+
+ if (string.IsNullOrWhiteSpace(image))
+ {
+ return null;
+ }
+
+ var ext = Path.GetExtension(image);
+
+ var outputPath = Path.ChangeExtension(outputPathWithoutExtension, ext);
+ FileSystem.CopyFile(image, outputPath, true);
+
+ return outputPath;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Intros/DefaultIntroProvider.cs b/Emby.Server.Implementations/Intros/DefaultIntroProvider.cs
new file mode 100644
index 000000000..180f6aba7
--- /dev/null
+++ b/Emby.Server.Implementations/Intros/DefaultIntroProvider.cs
@@ -0,0 +1,384 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Server.Implementations.Intros
+{
+ public class DefaultIntroProvider : IIntroProvider
+ {
+ private readonly ISecurityManager _security;
+ private readonly ILocalizationManager _localization;
+ private readonly IConfigurationManager _serverConfig;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
+ public DefaultIntroProvider(ISecurityManager security, ILocalizationManager localization, IConfigurationManager serverConfig, ILibraryManager libraryManager, IFileSystem fileSystem, IMediaSourceManager mediaSourceManager)
+ {
+ _security = security;
+ _localization = localization;
+ _serverConfig = serverConfig;
+ _libraryManager = libraryManager;
+ _fileSystem = fileSystem;
+ _mediaSourceManager = mediaSourceManager;
+ }
+
+ public async Task<IEnumerable<IntroInfo>> GetIntros(BaseItem item, User user)
+ {
+ var config = GetOptions();
+
+ if (item is Movie)
+ {
+ if (!config.EnableIntrosForMovies)
+ {
+ return new List<IntroInfo>();
+ }
+ }
+ else if (item is Episode)
+ {
+ if (!config.EnableIntrosForEpisodes)
+ {
+ return new List<IntroInfo>();
+ }
+ }
+ else
+ {
+ return new List<IntroInfo>();
+ }
+
+ var ratingLevel = string.IsNullOrWhiteSpace(item.OfficialRating)
+ ? null
+ : _localization.GetRatingLevel(item.OfficialRating);
+
+ var candidates = new List<ItemWithTrailer>();
+
+ var trailerTypes = new List<TrailerType>();
+ var sourceTypes = new List<SourceType>();
+
+ if (config.EnableIntrosFromMoviesInLibrary)
+ {
+ trailerTypes.Add(TrailerType.LocalTrailer);
+ sourceTypes.Add(SourceType.Library);
+ }
+
+ if (IsSupporter)
+ {
+ if (config.EnableIntrosFromUpcomingTrailers)
+ {
+ trailerTypes.Add(TrailerType.ComingSoonToTheaters);
+ sourceTypes.Clear();
+ }
+ if (config.EnableIntrosFromUpcomingDvdMovies)
+ {
+ trailerTypes.Add(TrailerType.ComingSoonToDvd);
+ sourceTypes.Clear();
+ }
+ if (config.EnableIntrosFromUpcomingStreamingMovies)
+ {
+ trailerTypes.Add(TrailerType.ComingSoonToStreaming);
+ sourceTypes.Clear();
+ }
+ if (config.EnableIntrosFromSimilarMovies)
+ {
+ trailerTypes.Add(TrailerType.Archive);
+ sourceTypes.Clear();
+ }
+ }
+
+ if (trailerTypes.Count > 0)
+ {
+ var trailerResult = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Trailer).Name },
+ TrailerTypes = trailerTypes.ToArray(),
+ SimilarTo = item,
+ IsPlayed = config.EnableIntrosForWatchedContent ? (bool?)null : false,
+ MaxParentalRating = config.EnableIntrosParentalControl ? ratingLevel : null,
+ BlockUnratedItems = config.EnableIntrosParentalControl ? new[] { UnratedItem.Trailer } : new UnratedItem[] { },
+
+ // Account for duplicates by imdb id, since the database doesn't support this yet
+ Limit = config.TrailerLimit * 2,
+ SourceTypes = sourceTypes.ToArray()
+
+ }).Where(i => string.IsNullOrWhiteSpace(i.GetProviderId(MetadataProviders.Imdb)) || !string.Equals(i.GetProviderId(MetadataProviders.Imdb), item.GetProviderId(MetadataProviders.Imdb), StringComparison.OrdinalIgnoreCase)).Take(config.TrailerLimit);
+
+ candidates.AddRange(trailerResult.Select(i => new ItemWithTrailer
+ {
+ Item = i,
+ Type = i.SourceType == SourceType.Channel ? ItemWithTrailerType.ChannelTrailer : ItemWithTrailerType.ItemWithTrailer,
+ LibraryManager = _libraryManager
+ }));
+ }
+
+ return GetResult(item, candidates, config);
+ }
+
+ private IEnumerable<IntroInfo> GetResult(BaseItem item, IEnumerable<ItemWithTrailer> candidates, CinemaModeConfiguration config)
+ {
+ var customIntros = !string.IsNullOrWhiteSpace(config.CustomIntroPath) ?
+ GetCustomIntros(config) :
+ new List<IntroInfo>();
+
+ var mediaInfoIntros = !string.IsNullOrWhiteSpace(config.MediaInfoIntroPath) ?
+ GetMediaInfoIntros(config, item) :
+ new List<IntroInfo>();
+
+ // Avoid implicitly captured closure
+ return candidates.Select(i => i.IntroInfo)
+ .Concat(customIntros.Take(1))
+ .Concat(mediaInfoIntros);
+ }
+
+ private CinemaModeConfiguration GetOptions()
+ {
+ return _serverConfig.GetConfiguration<CinemaModeConfiguration>("cinemamode");
+ }
+
+ private List<IntroInfo> GetCustomIntros(CinemaModeConfiguration options)
+ {
+ try
+ {
+ return GetCustomIntroFiles(options, true, false)
+ .OrderBy(i => Guid.NewGuid())
+ .Select(i => new IntroInfo
+ {
+ Path = i
+
+ }).ToList();
+ }
+ catch (IOException)
+ {
+ return new List<IntroInfo>();
+ }
+ }
+
+ private IEnumerable<IntroInfo> GetMediaInfoIntros(CinemaModeConfiguration options, BaseItem item)
+ {
+ try
+ {
+ var hasMediaSources = item as IHasMediaSources;
+
+ if (hasMediaSources == null)
+ {
+ return new List<IntroInfo>();
+ }
+
+ var mediaSource = _mediaSourceManager.GetStaticMediaSources(hasMediaSources, false)
+ .FirstOrDefault();
+
+ if (mediaSource == null)
+ {
+ return new List<IntroInfo>();
+ }
+
+ var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
+ var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
+
+ var allIntros = GetCustomIntroFiles(options, false, true)
+ .OrderBy(i => Guid.NewGuid())
+ .Select(i => new IntroInfo
+ {
+ Path = i
+
+ }).ToList();
+
+ var returnResult = new List<IntroInfo>();
+
+ if (videoStream != null)
+ {
+ returnResult.AddRange(GetMediaInfoIntrosByVideoStream(allIntros, videoStream).Take(1));
+ }
+
+ if (audioStream != null)
+ {
+ returnResult.AddRange(GetMediaInfoIntrosByAudioStream(allIntros, audioStream).Take(1));
+ }
+
+ returnResult.AddRange(GetMediaInfoIntrosByTags(allIntros, item.Tags).Take(1));
+
+ return returnResult.DistinctBy(i => i.Path, StringComparer.OrdinalIgnoreCase);
+ }
+ catch (IOException)
+ {
+ return new List<IntroInfo>();
+ }
+ }
+
+ private IEnumerable<IntroInfo> GetMediaInfoIntrosByVideoStream(List<IntroInfo> allIntros, MediaStream stream)
+ {
+ var codec = stream.Codec;
+
+ if (string.IsNullOrWhiteSpace(codec))
+ {
+ return new List<IntroInfo>();
+ }
+
+ return allIntros
+ .Where(i => IsMatch(i.Path, codec))
+ .OrderBy(i => Guid.NewGuid());
+ }
+
+ private IEnumerable<IntroInfo> GetMediaInfoIntrosByAudioStream(List<IntroInfo> allIntros, MediaStream stream)
+ {
+ var codec = stream.Codec;
+
+ if (string.IsNullOrWhiteSpace(codec))
+ {
+ return new List<IntroInfo>();
+ }
+
+ return allIntros
+ .Where(i => IsAudioMatch(i.Path, stream))
+ .OrderBy(i => Guid.NewGuid());
+ }
+
+ private IEnumerable<IntroInfo> GetMediaInfoIntrosByTags(List<IntroInfo> allIntros, List<string> tags)
+ {
+ return allIntros
+ .Where(i => tags.Any(t => IsMatch(i.Path, t)))
+ .OrderBy(i => Guid.NewGuid());
+ }
+
+ private bool IsMatch(string file, string attribute)
+ {
+ var filename = Path.GetFileNameWithoutExtension(file) ?? string.Empty;
+ filename = Normalize(filename);
+
+ if (string.IsNullOrWhiteSpace(filename))
+ {
+ return false;
+ }
+
+ attribute = Normalize(attribute);
+ if (string.IsNullOrWhiteSpace(attribute))
+ {
+ return false;
+ }
+
+ return string.Equals(filename, attribute, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private string Normalize(string value)
+ {
+ return value;
+ }
+
+ private bool IsAudioMatch(string path, MediaStream stream)
+ {
+ if (!string.IsNullOrWhiteSpace(stream.Codec))
+ {
+ if (IsMatch(path, stream.Codec))
+ {
+ return true;
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(stream.Profile))
+ {
+ if (IsMatch(path, stream.Profile))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private IEnumerable<string> GetCustomIntroFiles(CinemaModeConfiguration options, bool enableCustomIntros, bool enableMediaInfoIntros)
+ {
+ var list = new List<string>();
+
+ if (enableCustomIntros && !string.IsNullOrWhiteSpace(options.CustomIntroPath))
+ {
+ list.AddRange(_fileSystem.GetFilePaths(options.CustomIntroPath, true)
+ .Where(_libraryManager.IsVideoFile));
+ }
+
+ if (enableMediaInfoIntros && !string.IsNullOrWhiteSpace(options.MediaInfoIntroPath))
+ {
+ list.AddRange(_fileSystem.GetFilePaths(options.MediaInfoIntroPath, true)
+ .Where(_libraryManager.IsVideoFile));
+ }
+
+ return list.Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ public IEnumerable<string> GetAllIntroFiles()
+ {
+ return GetCustomIntroFiles(GetOptions(), true, true);
+ }
+
+ private bool IsSupporter
+ {
+ get { return _security.IsMBSupporter; }
+ }
+
+ public string Name
+ {
+ get { return "Default"; }
+ }
+
+ internal class ItemWithTrailer
+ {
+ internal BaseItem Item;
+ internal ItemWithTrailerType Type;
+ internal ILibraryManager LibraryManager;
+
+ public IntroInfo IntroInfo
+ {
+ get
+ {
+ var id = Item.Id;
+
+ if (Type == ItemWithTrailerType.ItemWithTrailer)
+ {
+ var hasTrailers = Item as IHasTrailers;
+
+ if (hasTrailers != null)
+ {
+ id = hasTrailers.LocalTrailerIds.FirstOrDefault();
+ }
+ }
+ return new IntroInfo
+ {
+ ItemId = id
+ };
+ }
+ }
+ }
+
+ internal enum ItemWithTrailerType
+ {
+ ChannelTrailer,
+ ItemWithTrailer
+ }
+ }
+
+ public class CinemaModeConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new[]
+ {
+ new ConfigurationStore
+ {
+ ConfigurationType = typeof(CinemaModeConfiguration),
+ Key = "cinemamode"
+ }
+ };
+ }
+ }
+
+}
diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
new file mode 100644
index 000000000..2e69cd2ef
--- /dev/null
+++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
@@ -0,0 +1,148 @@
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// Provides the core resolver ignore rules
+ /// </summary>
+ public class CoreResolutionIgnoreRule : IResolverIgnoreRule
+ {
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Any folder named in this list will be ignored - can be added to at runtime for extensibility
+ /// </summary>
+ public static readonly List<string> IgnoreFolders = new List<string>
+ {
+ "metadata",
+ "ps3_update",
+ "ps3_vprm",
+ "extrafanart",
+ "extrathumbs",
+ ".actors",
+ ".wd_tv",
+
+ // Synology
+ "@eaDir",
+ "eaDir",
+ "#recycle"
+
+ };
+
+ public CoreResolutionIgnoreRule(IFileSystem fileSystem, ILibraryManager libraryManager)
+ {
+ _fileSystem = fileSystem;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Shoulds the ignore.
+ /// </summary>
+ /// <param name="fileInfo">The file information.</param>
+ /// <param name="parent">The parent.</param>
+ /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns>
+ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent)
+ {
+ var filename = fileInfo.Name;
+ var isHidden = fileInfo.IsHidden;
+ var path = fileInfo.FullName;
+
+ // Handle mac .DS_Store
+ // https://github.com/MediaBrowser/MediaBrowser/issues/427
+ if (filename.IndexOf("._", StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ return true;
+ }
+
+ // Ignore hidden files and folders
+ if (isHidden)
+ {
+ if (parent == null)
+ {
+ var parentFolderName = Path.GetFileName(Path.GetDirectoryName(path));
+
+ if (string.Equals(parentFolderName, BaseItem.ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ if (string.Equals(parentFolderName, BaseItem.ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+
+ // Sometimes these are marked hidden
+ if (_fileSystem.IsRootPath(path))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (fileInfo.IsDirectory)
+ {
+ // Ignore any folders in our list
+ if (IgnoreFolders.Contains(filename, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (parent != null)
+ {
+ // Ignore trailer folders but allow it at the collection level
+ if (string.Equals(filename, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase) &&
+ !(parent is AggregateFolder) && !(parent is UserRootFolder))
+ {
+ return true;
+ }
+
+ if (string.Equals(filename, BaseItem.ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(filename, BaseItem.ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+ else
+ {
+ if (parent != null)
+ {
+ // Don't resolve these into audio files
+ if (string.Equals(_fileSystem.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename) && _libraryManager.IsAudioFile(filename))
+ {
+ return true;
+ }
+ }
+
+ // Ignore samples
+ var sampleFilename = " " + filename.Replace(".", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("-", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("_", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("!", " ", StringComparison.OrdinalIgnoreCase);
+
+ if (sampleFilename.IndexOf(" sample ", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
new file mode 100644
index 000000000..5bf53fcb4
--- /dev/null
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -0,0 +1,3138 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Naming.Audio;
+using MediaBrowser.Naming.Common;
+using MediaBrowser.Naming.TV;
+using MediaBrowser.Naming.Video;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Library.Resolvers;
+using Emby.Server.Implementations.Library.Validators;
+using Emby.Server.Implementations.ScheduledTasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Net;
+using SortOrder = MediaBrowser.Model.Entities.SortOrder;
+using VideoResolver = MediaBrowser.Naming.Video.VideoResolver;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// Class LibraryManager
+ /// </summary>
+ public class LibraryManager : ILibraryManager
+ {
+ /// <summary>
+ /// Gets or sets the postscan tasks.
+ /// </summary>
+ /// <value>The postscan tasks.</value>
+ private ILibraryPostScanTask[] PostscanTasks { get; set; }
+
+ /// <summary>
+ /// Gets the intro providers.
+ /// </summary>
+ /// <value>The intro providers.</value>
+ private IIntroProvider[] IntroProviders { get; set; }
+
+ /// <summary>
+ /// Gets the list of entity resolution ignore rules
+ /// </summary>
+ /// <value>The entity resolution ignore rules.</value>
+ private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; }
+
+ /// <summary>
+ /// Gets the list of BasePluginFolders added by plugins
+ /// </summary>
+ /// <value>The plugin folders.</value>
+ private IVirtualFolderCreator[] PluginFolderCreators { get; set; }
+
+ /// <summary>
+ /// Gets the list of currently registered entity resolvers
+ /// </summary>
+ /// <value>The entity resolvers enumerable.</value>
+ private IItemResolver[] EntityResolvers { get; set; }
+ private IMultiItemResolver[] MultiItemResolvers { get; set; }
+
+ /// <summary>
+ /// Gets or sets the comparers.
+ /// </summary>
+ /// <value>The comparers.</value>
+ private IBaseItemComparer[] Comparers { get; set; }
+
+ /// <summary>
+ /// Gets the active item repository
+ /// </summary>
+ /// <value>The item repository.</value>
+ public IItemRepository ItemRepository { get; set; }
+
+ /// <summary>
+ /// Occurs when [item added].
+ /// </summary>
+ public event EventHandler<ItemChangeEventArgs> ItemAdded;
+
+ /// <summary>
+ /// Occurs when [item updated].
+ /// </summary>
+ public event EventHandler<ItemChangeEventArgs> ItemUpdated;
+
+ /// <summary>
+ /// Occurs when [item removed].
+ /// </summary>
+ public event EventHandler<ItemChangeEventArgs> ItemRemoved;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// The _task manager
+ /// </summary>
+ private readonly ITaskManager _taskManager;
+
+ /// <summary>
+ /// The _user manager
+ /// </summary>
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// The _user data repository
+ /// </summary>
+ private readonly IUserDataManager _userDataRepository;
+
+ /// <summary>
+ /// Gets or sets the configuration manager.
+ /// </summary>
+ /// <value>The configuration manager.</value>
+ private IServerConfigurationManager ConfigurationManager { get; set; }
+
+ /// <summary>
+ /// A collection of items that may be referenced from multiple physical places in the library
+ /// (typically, multiple user roots). We store them here and be sure they all reference a
+ /// single instance.
+ /// </summary>
+ /// <value>The by reference items.</value>
+ private ConcurrentDictionary<Guid, BaseItem> ByReferenceItems { get; set; }
+
+ private readonly Func<ILibraryMonitor> _libraryMonitorFactory;
+ private readonly Func<IProviderManager> _providerManagerFactory;
+ private readonly Func<IUserViewManager> _userviewManager;
+ public bool IsScanRunning { get; private set; }
+
+ /// <summary>
+ /// The _library items cache
+ /// </summary>
+ private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache;
+ /// <summary>
+ /// Gets the library items cache.
+ /// </summary>
+ /// <value>The library items cache.</value>
+ private ConcurrentDictionary<Guid, BaseItem> LibraryItemsCache
+ {
+ get
+ {
+ return _libraryItemsCache;
+ }
+ }
+
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LibraryManager" /> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="taskManager">The task manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <param name="userDataRepository">The user data repository.</param>
+ public LibraryManager(ILogger logger, ITaskManager taskManager, IUserManager userManager, IServerConfigurationManager configurationManager, IUserDataManager userDataRepository, Func<ILibraryMonitor> libraryMonitorFactory, IFileSystem fileSystem, Func<IProviderManager> providerManagerFactory, Func<IUserViewManager> userviewManager)
+ {
+ _logger = logger;
+ _taskManager = taskManager;
+ _userManager = userManager;
+ ConfigurationManager = configurationManager;
+ _userDataRepository = userDataRepository;
+ _libraryMonitorFactory = libraryMonitorFactory;
+ _fileSystem = fileSystem;
+ _providerManagerFactory = providerManagerFactory;
+ _userviewManager = userviewManager;
+ ByReferenceItems = new ConcurrentDictionary<Guid, BaseItem>();
+ _libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>();
+
+ ConfigurationManager.ConfigurationUpdated += ConfigurationUpdated;
+
+ RecordConfigurationValues(configurationManager.Configuration);
+ }
+
+ /// <summary>
+ /// Adds the parts.
+ /// </summary>
+ /// <param name="rules">The rules.</param>
+ /// <param name="pluginFolders">The plugin folders.</param>
+ /// <param name="resolvers">The resolvers.</param>
+ /// <param name="introProviders">The intro providers.</param>
+ /// <param name="itemComparers">The item comparers.</param>
+ /// <param name="postscanTasks">The postscan tasks.</param>
+ public void AddParts(IEnumerable<IResolverIgnoreRule> rules,
+ IEnumerable<IVirtualFolderCreator> pluginFolders,
+ IEnumerable<IItemResolver> resolvers,
+ IEnumerable<IIntroProvider> introProviders,
+ IEnumerable<IBaseItemComparer> itemComparers,
+ IEnumerable<ILibraryPostScanTask> postscanTasks)
+ {
+ EntityResolutionIgnoreRules = rules.ToArray();
+ PluginFolderCreators = pluginFolders.ToArray();
+ EntityResolvers = resolvers.OrderBy(i => i.Priority).ToArray();
+ MultiItemResolvers = EntityResolvers.OfType<IMultiItemResolver>().ToArray();
+ IntroProviders = introProviders.ToArray();
+ Comparers = itemComparers.ToArray();
+
+ PostscanTasks = postscanTasks.OrderBy(i =>
+ {
+ var hasOrder = i as IHasOrder;
+
+ return hasOrder == null ? 0 : hasOrder.Order;
+
+ }).ToArray();
+ }
+
+ /// <summary>
+ /// The _root folder
+ /// </summary>
+ private volatile AggregateFolder _rootFolder;
+ /// <summary>
+ /// The _root folder sync lock
+ /// </summary>
+ private readonly object _rootFolderSyncLock = new object();
+ /// <summary>
+ /// Gets the root folder.
+ /// </summary>
+ /// <value>The root folder.</value>
+ public AggregateFolder RootFolder
+ {
+ get
+ {
+ if (_rootFolder == null)
+ {
+ lock (_rootFolderSyncLock)
+ {
+ if (_rootFolder == null)
+ {
+ _rootFolder = CreateRootFolder();
+ }
+ }
+ }
+ return _rootFolder;
+ }
+ }
+
+ /// <summary>
+ /// The _season zero display name
+ /// </summary>
+ private string _seasonZeroDisplayName;
+
+ private bool _wizardCompleted;
+ /// <summary>
+ /// Records the configuration values.
+ /// </summary>
+ /// <param name="configuration">The configuration.</param>
+ private void RecordConfigurationValues(ServerConfiguration configuration)
+ {
+ _seasonZeroDisplayName = configuration.SeasonZeroDisplayName;
+ _wizardCompleted = configuration.IsStartupWizardCompleted;
+ }
+
+ /// <summary>
+ /// Configurations the updated.
+ /// </summary>
+ /// <param name="sender">The sender.</param>
+ /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
+ void ConfigurationUpdated(object sender, EventArgs e)
+ {
+ var config = ConfigurationManager.Configuration;
+
+ var newSeasonZeroName = ConfigurationManager.Configuration.SeasonZeroDisplayName;
+ var seasonZeroNameChanged = !string.Equals(_seasonZeroDisplayName, newSeasonZeroName, StringComparison.Ordinal);
+ var wizardChanged = config.IsStartupWizardCompleted != _wizardCompleted;
+
+ RecordConfigurationValues(config);
+
+ if (seasonZeroNameChanged || wizardChanged)
+ {
+ _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
+ }
+
+ if (seasonZeroNameChanged)
+ {
+ Task.Run(async () =>
+ {
+ await UpdateSeasonZeroNames(newSeasonZeroName, CancellationToken.None).ConfigureAwait(false);
+
+ });
+ }
+ }
+
+ /// <summary>
+ /// Updates the season zero names.
+ /// </summary>
+ /// <param name="newName">The new name.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task UpdateSeasonZeroNames(string newName, CancellationToken cancellationToken)
+ {
+ var seasons = GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Season).Name },
+ Recursive = true,
+ IndexNumber = 0
+
+ }).Cast<Season>()
+ .Where(i => !string.Equals(i.Name, newName, StringComparison.Ordinal))
+ .ToList();
+
+ foreach (var season in seasons)
+ {
+ season.Name = newName;
+
+ try
+ {
+ await UpdateItem(season, ItemUpdateType.MetadataDownload, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving {0}", ex, season.Path);
+ }
+ }
+ }
+
+ public void RegisterItem(BaseItem item)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+ if (item is IItemByName)
+ {
+ if (!(item is MusicArtist) && !(item is Studio))
+ {
+ return;
+ }
+ }
+
+ else if (item.IsFolder)
+ {
+ //if (!(item is ICollectionFolder) && !(item is UserView) && !(item is Channel) && !(item is AggregateFolder))
+ //{
+ // if (item.SourceType != SourceType.Library)
+ // {
+ // return;
+ // }
+ //}
+ }
+ else
+ {
+ if (item is Photo)
+ {
+ return;
+ }
+ }
+
+ LibraryItemsCache.AddOrUpdate(item.Id, item, delegate { return item; });
+ }
+
+ public async Task DeleteItem(BaseItem item, DeleteOptions options)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ _logger.Debug("Deleting item, Type: {0}, Name: {1}, Path: {2}, Id: {3}",
+ item.GetType().Name,
+ item.Name ?? "Unknown name",
+ item.Path ?? string.Empty,
+ item.Id);
+
+ var parent = item.Parent;
+
+ var locationType = item.LocationType;
+
+ var children = item.IsFolder
+ ? ((Folder)item).GetRecursiveChildren(false).ToList()
+ : new List<BaseItem>();
+
+ foreach (var metadataPath in GetMetadataPaths(item, children))
+ {
+ _logger.Debug("Deleting path {0}", metadataPath);
+
+ try
+ {
+ _fileSystem.DeleteDirectory(metadataPath, true);
+ }
+ catch (IOException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting {0}", ex, metadataPath);
+ }
+ }
+
+ if (options.DeleteFileLocation && locationType != LocationType.Remote && locationType != LocationType.Virtual)
+ {
+ foreach (var path in item.GetDeletePaths().ToList())
+ {
+ if (_fileSystem.DirectoryExists(path))
+ {
+ _logger.Debug("Deleting path {0}", path);
+ _fileSystem.DeleteDirectory(path, true);
+ }
+ else if (_fileSystem.FileExists(path))
+ {
+ _logger.Debug("Deleting path {0}", path);
+ _fileSystem.DeleteFile(path);
+ }
+ }
+
+ if (parent != null)
+ {
+ await parent.ValidateChildren(new Progress<double>(), CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+ else if (parent != null)
+ {
+ parent.RemoveChild(item);
+ }
+
+ await ItemRepository.DeleteItem(item.Id, CancellationToken.None).ConfigureAwait(false);
+ foreach (var child in children)
+ {
+ await ItemRepository.DeleteItem(child.Id, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ BaseItem removed;
+ _libraryItemsCache.TryRemove(item.Id, out removed);
+
+ ReportItemRemoved(item);
+ }
+
+ private IEnumerable<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
+ {
+ var list = new List<string>
+ {
+ item.GetInternalMetadataPath()
+ };
+
+ list.AddRange(children.Select(i => i.GetInternalMetadataPath()));
+
+ return list;
+ }
+
+ /// <summary>
+ /// Resolves the item.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <param name="resolvers">The resolvers.</param>
+ /// <returns>BaseItem.</returns>
+ private BaseItem ResolveItem(ItemResolveArgs args, IItemResolver[] resolvers)
+ {
+ var item = (resolvers ?? EntityResolvers).Select(r => Resolve(args, r))
+ .FirstOrDefault(i => i != null);
+
+ if (item != null)
+ {
+ ResolverHelper.SetInitialItemValues(item, args, _fileSystem, this);
+ }
+
+ return item;
+ }
+
+ private BaseItem Resolve(ItemResolveArgs args, IItemResolver resolver)
+ {
+ try
+ {
+ return resolver.ResolvePath(args);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in {0} resolving {1}", ex, resolver.GetType().Name, args.Path);
+ return null;
+ }
+ }
+
+ public Guid GetNewItemId(string key, Type type)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new ArgumentNullException("key");
+ }
+ if (type == null)
+ {
+ throw new ArgumentNullException("type");
+ }
+
+ if (ConfigurationManager.Configuration.EnableLocalizedGuids && key.StartsWith(ConfigurationManager.ApplicationPaths.ProgramDataPath))
+ {
+ // Try to normalize paths located underneath program-data in an attempt to make them more portable
+ key = key.Substring(ConfigurationManager.ApplicationPaths.ProgramDataPath.Length)
+ .TrimStart(new[] { '/', '\\' })
+ .Replace("/", "\\");
+ }
+
+ if (!ConfigurationManager.Configuration.EnableCaseSensitiveItemIds)
+ {
+ key = key.ToLower();
+ }
+
+ key = type.FullName + key;
+
+ return key.GetMD5();
+ }
+
+ /// <summary>
+ /// Ensure supplied item has only one instance throughout
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>The proper instance to the item</returns>
+ public BaseItem GetOrAddByReferenceItem(BaseItem item)
+ {
+ // Add this item to our list if not there already
+ if (!ByReferenceItems.TryAdd(item.Id, item))
+ {
+ // Already there - return the existing reference
+ item = ByReferenceItems[item.Id];
+ }
+ return item;
+ }
+
+ public BaseItem ResolvePath(FileSystemMetadata fileInfo,
+ Folder parent = null)
+ {
+ return ResolvePath(fileInfo, new DirectoryService(_logger, _fileSystem), null, parent);
+ }
+
+ private BaseItem ResolvePath(FileSystemMetadata fileInfo,
+ IDirectoryService directoryService,
+ IItemResolver[] resolvers,
+ Folder parent = null,
+ string collectionType = null,
+ LibraryOptions libraryOptions = null)
+ {
+ if (fileInfo == null)
+ {
+ throw new ArgumentNullException("fileInfo");
+ }
+
+ var fullPath = fileInfo.FullName;
+
+ if (string.IsNullOrWhiteSpace(collectionType) && parent != null)
+ {
+ collectionType = GetContentTypeOverride(fullPath, true);
+ }
+
+ var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService)
+ {
+ Parent = parent,
+ Path = fullPath,
+ FileInfo = fileInfo,
+ CollectionType = collectionType,
+ LibraryOptions = libraryOptions
+ };
+
+ // Return null if ignore rules deem that we should do so
+ if (IgnoreFile(args.FileInfo, args.Parent))
+ {
+ return null;
+ }
+
+ // Gather child folder and files
+ if (args.IsDirectory)
+ {
+ var isPhysicalRoot = args.IsPhysicalRoot;
+
+ // When resolving the root, we need it's grandchildren (children of user views)
+ var flattenFolderDepth = isPhysicalRoot ? 2 : 0;
+
+ var fileSystemDictionary = FileData.GetFilteredFileSystemEntries(directoryService, args.Path, _fileSystem, _logger, args, flattenFolderDepth: flattenFolderDepth, resolveShortcuts: isPhysicalRoot || args.IsVf);
+
+ // Need to remove subpaths that may have been resolved from shortcuts
+ // Example: if \\server\movies exists, then strip out \\server\movies\action
+ if (isPhysicalRoot)
+ {
+ var paths = NormalizeRootPathList(fileSystemDictionary.Values);
+
+ fileSystemDictionary = paths.ToDictionary(i => i.FullName);
+ }
+
+ args.FileSystemDictionary = fileSystemDictionary;
+ }
+
+ // Check to see if we should resolve based on our contents
+ if (args.IsDirectory && !ShouldResolvePathContents(args))
+ {
+ return null;
+ }
+
+ return ResolveItem(args, resolvers);
+ }
+
+ private readonly List<string> _ignoredPaths = new List<string>();
+
+ public void RegisterIgnoredPath(string path)
+ {
+ lock (_ignoredPaths)
+ {
+ _ignoredPaths.Add(path);
+ }
+ }
+ public void UnRegisterIgnoredPath(string path)
+ {
+ lock (_ignoredPaths)
+ {
+ _ignoredPaths.Remove(path);
+ }
+ }
+
+ public bool IgnoreFile(FileSystemMetadata file, BaseItem parent)
+ {
+ if (EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(file, parent)))
+ {
+ return true;
+ }
+
+ //lock (_ignoredPaths)
+ {
+ if (_ignoredPaths.Contains(file.FullName, StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public IEnumerable<FileSystemMetadata> NormalizeRootPathList(IEnumerable<FileSystemMetadata> paths)
+ {
+ var originalList = paths.ToList();
+
+ var list = originalList.Where(i => i.IsDirectory)
+ .Select(i => _fileSystem.NormalizePath(i.FullName))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var dupes = list.Where(subPath => !subPath.EndsWith(":\\", StringComparison.OrdinalIgnoreCase) && list.Any(i => _fileSystem.ContainsSubPath(i, subPath)))
+ .ToList();
+
+ foreach (var dupe in dupes)
+ {
+ _logger.Info("Found duplicate path: {0}", dupe);
+ }
+
+ var newList = list.Except(dupes, StringComparer.OrdinalIgnoreCase).Select(_fileSystem.GetDirectoryInfo).ToList();
+ newList.AddRange(originalList.Where(i => !i.IsDirectory));
+ return newList;
+ }
+
+ /// <summary>
+ /// Determines whether a path should be ignored based on its contents - called after the contents have been read
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns>
+ private static bool ShouldResolvePathContents(ItemResolveArgs args)
+ {
+ // Ignore any folders containing a file called .ignore
+ return !args.ContainsFileSystemEntryByName(".ignore");
+ }
+
+ public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, string collectionType)
+ {
+ return ResolvePaths(files, directoryService, parent, libraryOptions, collectionType, EntityResolvers);
+ }
+
+ public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files,
+ IDirectoryService directoryService,
+ Folder parent,
+ LibraryOptions libraryOptions,
+ string collectionType,
+ IItemResolver[] resolvers)
+ {
+ var fileList = files.Where(i => !IgnoreFile(i, parent)).ToList();
+
+ if (parent != null)
+ {
+ var multiItemResolvers = resolvers == null ? MultiItemResolvers : resolvers.OfType<IMultiItemResolver>().ToArray();
+
+ foreach (var resolver in multiItemResolvers)
+ {
+ var result = resolver.ResolveMultiple(parent, fileList, collectionType, directoryService);
+
+ if (result != null && result.Items.Count > 0)
+ {
+ var items = new List<BaseItem>();
+ items.AddRange(result.Items);
+
+ foreach (var item in items)
+ {
+ ResolverHelper.SetInitialItemValues(item, parent, _fileSystem, this, directoryService);
+ }
+ items.AddRange(ResolveFileList(result.ExtraFiles, directoryService, parent, collectionType, resolvers, libraryOptions));
+ return items;
+ }
+ }
+ }
+
+ return ResolveFileList(fileList, directoryService, parent, collectionType, resolvers, libraryOptions);
+ }
+
+ private IEnumerable<BaseItem> ResolveFileList(IEnumerable<FileSystemMetadata> fileList,
+ IDirectoryService directoryService,
+ Folder parent,
+ string collectionType,
+ IItemResolver[] resolvers,
+ LibraryOptions libraryOptions)
+ {
+ return fileList.Select(f =>
+ {
+ try
+ {
+ return ResolvePath(f, directoryService, resolvers, parent, collectionType, libraryOptions);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error resolving path {0}", ex, f.FullName);
+ return null;
+ }
+ }).Where(i => i != null);
+ }
+
+ /// <summary>
+ /// Creates the root media folder
+ /// </summary>
+ /// <returns>AggregateFolder.</returns>
+ /// <exception cref="System.InvalidOperationException">Cannot create the root folder until plugins have loaded</exception>
+ public AggregateFolder CreateRootFolder()
+ {
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.RootFolderPath;
+
+ _fileSystem.CreateDirectory(rootFolderPath);
+
+ var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ?? (AggregateFolder)ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath));
+
+ // Add in the plug-in folders
+ foreach (var child in PluginFolderCreators)
+ {
+ var folder = child.GetFolder();
+
+ if (folder != null)
+ {
+ if (folder.Id == Guid.Empty)
+ {
+ if (string.IsNullOrWhiteSpace(folder.Path))
+ {
+ folder.Id = GetNewItemId(folder.GetType().Name, folder.GetType());
+ }
+ else
+ {
+ folder.Id = GetNewItemId(folder.Path, folder.GetType());
+ }
+ }
+
+ var dbItem = GetItemById(folder.Id) as BasePluginFolder;
+
+ if (dbItem != null && string.Equals(dbItem.Path, folder.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ folder = dbItem;
+ }
+
+ if (folder.ParentId != rootFolder.Id)
+ {
+ folder.ParentId = rootFolder.Id;
+ var task = folder.UpdateToRepository(ItemUpdateType.MetadataImport, CancellationToken.None);
+ Task.WaitAll(task);
+ }
+
+ rootFolder.AddVirtualChild(folder);
+
+ RegisterItem(folder);
+ }
+ }
+
+ return rootFolder;
+ }
+
+ private volatile UserRootFolder _userRootFolder;
+ private readonly object _syncLock = new object();
+ public Folder GetUserRootFolder()
+ {
+ if (_userRootFolder == null)
+ {
+ lock (_syncLock)
+ {
+ if (_userRootFolder == null)
+ {
+ var userRootPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+
+ _fileSystem.CreateDirectory(userRootPath);
+
+ var tmpItem = GetItemById(GetNewItemId(userRootPath, typeof(UserRootFolder))) as UserRootFolder;
+
+ if (tmpItem == null)
+ {
+ tmpItem = (UserRootFolder)ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath));
+ }
+
+ _userRootFolder = tmpItem;
+ }
+ }
+ }
+
+ return _userRootFolder;
+ }
+
+ public Guid? FindIdByPath(string path, bool? isFolder)
+ {
+ // If this returns multiple items it could be tricky figuring out which one is correct.
+ // In most cases, the newest one will be and the others obsolete but not yet cleaned up
+
+ var query = new InternalItemsQuery
+ {
+ Path = path,
+ IsFolder = isFolder,
+ SortBy = new[] { ItemSortBy.DateCreated },
+ SortOrder = SortOrder.Descending,
+ Limit = 1
+ };
+
+ var id = GetItemIds(query);
+
+ if (id.Count == 0)
+ {
+ return null;
+ }
+
+ return id[0];
+ }
+
+ public BaseItem FindByPath(string path, bool? isFolder)
+ {
+ // If this returns multiple items it could be tricky figuring out which one is correct.
+ // In most cases, the newest one will be and the others obsolete but not yet cleaned up
+
+ var query = new InternalItemsQuery
+ {
+ Path = path,
+ IsFolder = isFolder,
+ SortBy = new[] { ItemSortBy.DateCreated },
+ SortOrder = SortOrder.Descending,
+ Limit = 1
+ };
+
+ return GetItemList(query)
+ .FirstOrDefault();
+ }
+
+ /// <summary>
+ /// Gets a Person
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{Person}.</returns>
+ public Person GetPerson(string name)
+ {
+ return CreateItemByName<Person>(Person.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets a Studio
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{Studio}.</returns>
+ public Studio GetStudio(string name)
+ {
+ return CreateItemByName<Studio>(Studio.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets a Genre
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{Genre}.</returns>
+ public Genre GetGenre(string name)
+ {
+ return CreateItemByName<Genre>(Genre.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets the genre.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{MusicGenre}.</returns>
+ public MusicGenre GetMusicGenre(string name)
+ {
+ return CreateItemByName<MusicGenre>(MusicGenre.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets the game genre.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{GameGenre}.</returns>
+ public GameGenre GetGameGenre(string name)
+ {
+ return CreateItemByName<GameGenre>(GameGenre.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets a Year
+ /// </summary>
+ /// <param name="value">The value.</param>
+ /// <returns>Task{Year}.</returns>
+ /// <exception cref="System.ArgumentOutOfRangeException"></exception>
+ public Year GetYear(int value)
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException("Years less than or equal to 0 are invalid.");
+ }
+
+ var name = value.ToString(CultureInfo.InvariantCulture);
+
+ return CreateItemByName<Year>(Year.GetPath(name), name);
+ }
+
+ /// <summary>
+ /// Gets a Genre
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>Task{Genre}.</returns>
+ public MusicArtist GetArtist(string name)
+ {
+ return CreateItemByName<MusicArtist>(MusicArtist.GetPath(name), name);
+ }
+
+ private T CreateItemByName<T>(string path, string name)
+ where T : BaseItem, new()
+ {
+ if (typeof(T) == typeof(MusicArtist))
+ {
+ var existing = GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(T).Name },
+ Name = name
+
+ }).Cast<MusicArtist>()
+ .OrderBy(i => i.IsAccessedByName ? 1 : 0)
+ .Cast<T>()
+ .FirstOrDefault();
+
+ if (existing != null)
+ {
+ return existing;
+ }
+ }
+
+ var id = GetNewItemId(path, typeof(T));
+
+ var item = GetItemById(id) as T;
+
+ if (item == null)
+ {
+ item = new T
+ {
+ Name = name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ Path = path
+ };
+
+ var task = CreateItem(item, CancellationToken.None);
+ Task.WaitAll(task);
+ }
+
+ return item;
+ }
+
+ public IEnumerable<MusicArtist> GetAlbumArtists(IEnumerable<IHasAlbumArtist> items)
+ {
+ var names = items
+ .SelectMany(i => i.AlbumArtists)
+ .DistinctNames()
+ .Select(i =>
+ {
+ try
+ {
+ var artist = GetArtist(i);
+
+ return artist;
+ }
+ catch
+ {
+ // Already logged at lower levels
+ return null;
+ }
+ })
+ .Where(i => i != null);
+
+ return names;
+ }
+
+ public IEnumerable<MusicArtist> GetArtists(IEnumerable<IHasArtist> items)
+ {
+ var names = items
+ .SelectMany(i => i.AllArtists)
+ .DistinctNames()
+ .Select(i =>
+ {
+ try
+ {
+ var artist = GetArtist(i);
+
+ return artist;
+ }
+ catch
+ {
+ // Already logged at lower levels
+ return null;
+ }
+ })
+ .Where(i => i != null);
+
+ return names;
+ }
+
+ /// <summary>
+ /// Validate and refresh the People sub-set of the IBN.
+ /// The items are stored in the db but not loaded into memory until actually requested by an operation.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ // Ensure the location is available.
+ _fileSystem.CreateDirectory(ConfigurationManager.ApplicationPaths.PeoplePath);
+
+ return new PeopleValidator(this, _logger, ConfigurationManager, _fileSystem).ValidatePeople(cancellationToken, progress);
+ }
+
+ /// <summary>
+ /// Reloads the root media folder
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task ValidateMediaLibrary(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ // Just run the scheduled task so that the user can see it
+ _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
+
+ return Task.FromResult(true);
+ }
+
+ /// <summary>
+ /// Queues the library scan.
+ /// </summary>
+ public void QueueLibraryScan()
+ {
+ // Just run the scheduled task so that the user can see it
+ _taskManager.QueueScheduledTask<RefreshMediaLibraryTask>();
+ }
+
+ /// <summary>
+ /// Validates the media library internal.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ IsScanRunning = true;
+ _libraryMonitorFactory().Stop();
+
+ try
+ {
+ await PerformLibraryValidation(progress, cancellationToken).ConfigureAwait(false);
+
+ if (!ConfigurationManager.Configuration.EnableSeriesPresentationUniqueKey)
+ {
+ ConfigurationManager.Configuration.EnableSeriesPresentationUniqueKey = true;
+ ConfigurationManager.SaveConfiguration();
+ }
+ }
+ finally
+ {
+ _libraryMonitorFactory().Start();
+ IsScanRunning = false;
+ }
+ }
+
+ private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ _logger.Info("Validating media library");
+
+ // Ensure these objects are lazy loaded.
+ // Without this there is a deadlock that will need to be investigated
+ var rootChildren = RootFolder.Children.ToList();
+ rootChildren = GetUserRootFolder().Children.ToList();
+
+ await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+
+ progress.Report(.5);
+
+ // Start by just validating the children of the root, but go no further
+ await RootFolder.ValidateChildren(new Progress<double>(), cancellationToken, new MetadataRefreshOptions(_fileSystem), recursive: false);
+
+ progress.Report(1);
+
+ await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false);
+
+ await GetUserRootFolder().ValidateChildren(new Progress<double>(), cancellationToken, new MetadataRefreshOptions(_fileSystem), recursive: false).ConfigureAwait(false);
+ progress.Report(2);
+
+ // Quickly scan CollectionFolders for changes
+ foreach (var folder in GetUserRootFolder().Children.OfType<Folder>().ToList())
+ {
+ await folder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ progress.Report(3);
+
+ var innerProgress = new ActionableProgress<double>();
+
+ innerProgress.RegisterAction(pct => progress.Report(3 + pct * .72));
+
+ // Now validate the entire media library
+ await RootFolder.ValidateChildren(innerProgress, cancellationToken, new MetadataRefreshOptions(_fileSystem), recursive: true).ConfigureAwait(false);
+
+ progress.Report(75);
+
+ innerProgress = new ActionableProgress<double>();
+
+ innerProgress.RegisterAction(pct => progress.Report(75 + pct * .25));
+
+ // Run post-scan tasks
+ await RunPostScanTasks(innerProgress, cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ /// <summary>
+ /// Runs the post scan tasks.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task RunPostScanTasks(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var tasks = PostscanTasks.ToList();
+
+ var numComplete = 0;
+ var numTasks = tasks.Count;
+
+ foreach (var task in tasks)
+ {
+ var innerProgress = new ActionableProgress<double>();
+
+ // Prevent access to modified closure
+ var currentNumComplete = numComplete;
+
+ innerProgress.RegisterAction(pct =>
+ {
+ double innerPercent = currentNumComplete * 100 + pct;
+ innerPercent /= numTasks;
+ progress.Report(innerPercent);
+ });
+
+ _logger.Debug("Running post-scan task {0}", task.GetType().Name);
+
+ try
+ {
+ await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.Info("Post-scan task cancelled: {0}", task.GetType().Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error running postscan task", ex);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= numTasks;
+ progress.Report(percent * 100);
+ }
+
+ progress.Report(100);
+ }
+
+ /// <summary>
+ /// Gets the default view.
+ /// </summary>
+ /// <returns>IEnumerable{VirtualFolderInfo}.</returns>
+ public IEnumerable<VirtualFolderInfo> GetVirtualFolders()
+ {
+ return GetView(ConfigurationManager.ApplicationPaths.DefaultUserViewsPath);
+ }
+
+ /// <summary>
+ /// Gets the view.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns>IEnumerable{VirtualFolderInfo}.</returns>
+ private IEnumerable<VirtualFolderInfo> GetView(string path)
+ {
+ var topLibraryFolders = GetUserRootFolder().Children.ToList();
+
+ return _fileSystem.GetDirectoryPaths(path)
+ .Select(dir => GetVirtualFolderInfo(dir, topLibraryFolders));
+ }
+
+ private VirtualFolderInfo GetVirtualFolderInfo(string dir, List<BaseItem> allCollectionFolders)
+ {
+ var info = new VirtualFolderInfo
+ {
+ Name = Path.GetFileName(dir),
+
+ Locations = _fileSystem.GetFilePaths(dir, false)
+ .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase))
+ .Select(_fileSystem.ResolveShortcut)
+ .OrderBy(i => i)
+ .ToList(),
+
+ CollectionType = GetCollectionType(dir)
+ };
+
+ var libraryFolder = allCollectionFolders.FirstOrDefault(i => string.Equals(i.Path, dir, StringComparison.OrdinalIgnoreCase));
+
+ if (libraryFolder != null && libraryFolder.HasImage(ImageType.Primary))
+ {
+ info.PrimaryImageItemId = libraryFolder.Id.ToString("N");
+ }
+
+ if (libraryFolder != null)
+ {
+ info.ItemId = libraryFolder.Id.ToString("N");
+ info.LibraryOptions = GetLibraryOptions(libraryFolder);
+ }
+
+ return info;
+ }
+
+ private string GetCollectionType(string path)
+ {
+ return _fileSystem.GetFiles(path, false)
+ .Where(i => string.Equals(i.Extension, ".collection", StringComparison.OrdinalIgnoreCase))
+ .Select(i => _fileSystem.GetFileNameWithoutExtension(i))
+ .FirstOrDefault();
+ }
+
+ /// <summary>
+ /// Gets the item by id.
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <returns>BaseItem.</returns>
+ /// <exception cref="System.ArgumentNullException">id</exception>
+ public BaseItem GetItemById(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ BaseItem item;
+
+ if (LibraryItemsCache.TryGetValue(id, out item))
+ {
+ return item;
+ }
+
+ item = RetrieveItem(id);
+
+ //_logger.Debug("GetitemById {0}", id);
+
+ if (item != null)
+ {
+ RegisterItem(item);
+ }
+
+ return item;
+ }
+
+ public IEnumerable<BaseItem> GetItemList(InternalItemsQuery query)
+ {
+ if (query.Recursive && query.ParentId.HasValue)
+ {
+ var parent = GetItemById(query.ParentId.Value);
+ if (parent != null)
+ {
+ SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+ }
+ }
+
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ return ItemRepository.GetItemList(query);
+ }
+
+ public int GetCount(InternalItemsQuery query)
+ {
+ if (query.Recursive && query.ParentId.HasValue)
+ {
+ var parent = GetItemById(query.ParentId.Value);
+ if (parent != null)
+ {
+ SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+ }
+ }
+
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ return ItemRepository.GetCount(query);
+ }
+
+ public IEnumerable<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents)
+ {
+ SetTopParentIdsOrAncestors(query, parents);
+
+ if (query.AncestorIds.Length == 0 && query.TopParentIds.Length == 0)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+ }
+
+ return ItemRepository.GetItemList(query);
+ }
+
+ public QueryResult<BaseItem> QueryItems(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ if (query.EnableTotalRecordCount)
+ {
+ return ItemRepository.GetItems(query);
+ }
+
+ return new QueryResult<BaseItem>
+ {
+ Items = ItemRepository.GetItemList(query).ToArray()
+ };
+ }
+
+ public List<Guid> GetItemIds(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ return ItemRepository.GetItemIdsList(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetStudios(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetStudios(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetGenres(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetGenres(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetGameGenres(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetGameGenres(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetMusicGenres(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetMusicGenres(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetAllArtists(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetAllArtists(query);
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetArtists(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetArtists(query);
+ }
+
+ private void SetTopParentOrAncestorIds(InternalItemsQuery query)
+ {
+ if (query.AncestorIds.Length == 0)
+ {
+ return;
+ }
+
+ var parents = query.AncestorIds.Select(i => GetItemById(new Guid(i))).ToList();
+
+ if (parents.All(i =>
+ {
+ if (i is ICollectionFolder || i is UserView)
+ {
+ return true;
+ }
+
+ //_logger.Debug("Query requires ancestor query due to type: " + i.GetType().Name);
+ return false;
+
+ }))
+ {
+ // Optimize by querying against top level views
+ query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).Select(i => i.ToString("N")).ToArray();
+ query.AncestorIds = new string[] { };
+
+ // Prevent searching in all libraries due to empty filter
+ if (query.TopParentIds.Length == 0)
+ {
+ query.TopParentIds = new[] { Guid.NewGuid().ToString("N") };
+ }
+ }
+ }
+
+ public QueryResult<Tuple<BaseItem, ItemCounts>> GetAlbumArtists(InternalItemsQuery query)
+ {
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return ItemRepository.GetAlbumArtists(query);
+ }
+
+ public QueryResult<BaseItem> GetItemsResult(InternalItemsQuery query)
+ {
+ if (query.Recursive && query.ParentId.HasValue)
+ {
+ var parent = GetItemById(query.ParentId.Value);
+ if (parent != null)
+ {
+ SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+ }
+ }
+
+ if (query.User != null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ if (query.EnableTotalRecordCount)
+ {
+ return ItemRepository.GetItems(query);
+ }
+
+ return new QueryResult<BaseItem>
+ {
+ Items = ItemRepository.GetItemList(query).ToArray()
+ };
+ }
+
+ private void SetTopParentIdsOrAncestors(InternalItemsQuery query, List<BaseItem> parents)
+ {
+ if (parents.All(i =>
+ {
+ if (i is ICollectionFolder || i is UserView)
+ {
+ return true;
+ }
+
+ //_logger.Debug("Query requires ancestor query due to type: " + i.GetType().Name);
+ return false;
+
+ }))
+ {
+ // Optimize by querying against top level views
+ query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).Select(i => i.ToString("N")).ToArray();
+
+ // Prevent searching in all libraries due to empty filter
+ if (query.TopParentIds.Length == 0)
+ {
+ query.TopParentIds = new[] { Guid.NewGuid().ToString("N") };
+ }
+ }
+ else
+ {
+ // We need to be able to query from any arbitrary ancestor up the tree
+ query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).Select(i => i.ToString("N")).ToArray();
+
+ // Prevent searching in all libraries due to empty filter
+ if (query.AncestorIds.Length == 0)
+ {
+ query.AncestorIds = new[] { Guid.NewGuid().ToString("N") };
+ }
+ }
+
+ query.ParentId = null;
+ }
+
+ private void AddUserToQuery(InternalItemsQuery query, User user)
+ {
+ if (query.AncestorIds.Length == 0 &&
+ !query.ParentId.HasValue &&
+ query.ChannelIds.Length == 0 &&
+ query.TopParentIds.Length == 0 &&
+ string.IsNullOrWhiteSpace(query.AncestorWithPresentationUniqueKey) &&
+ string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey) &&
+ query.ItemIds.Length == 0)
+ {
+ var userViews = _userviewManager().GetUserViews(new UserViewQuery
+ {
+ UserId = user.Id.ToString("N"),
+ IncludeHidden = true
+
+ }, CancellationToken.None).Result.ToList();
+
+ query.TopParentIds = userViews.SelectMany(i => GetTopParentIdsForQuery(i, user)).Select(i => i.ToString("N")).ToArray();
+ }
+ }
+
+ private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User user)
+ {
+ var view = item as UserView;
+
+ if (view != null)
+ {
+ if (string.Equals(view.ViewType, CollectionType.LiveTv))
+ {
+ return new[] { view.Id };
+ }
+ if (string.Equals(view.ViewType, CollectionType.Channels))
+ {
+ var channelResult = BaseItem.ChannelManager.GetChannelsInternal(new ChannelQuery
+ {
+ UserId = user.Id.ToString("N")
+
+ }, CancellationToken.None).Result;
+
+ return channelResult.Items.Select(i => i.Id);
+ }
+
+ // Translate view into folders
+ if (view.DisplayParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(view.DisplayParentId);
+ if (displayParent != null)
+ {
+ return GetTopParentIdsForQuery(displayParent, user);
+ }
+ return new Guid[] { };
+ }
+ if (view.ParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(view.ParentId);
+ if (displayParent != null)
+ {
+ return GetTopParentIdsForQuery(displayParent, user);
+ }
+ return new Guid[] { };
+ }
+
+ // Handle grouping
+ if (user != null && !string.IsNullOrWhiteSpace(view.ViewType) && UserView.IsEligibleForGrouping(view.ViewType) && user.Configuration.GroupedFolders.Length > 0)
+ {
+ return user.RootFolder
+ .GetChildren(user, true)
+ .OfType<CollectionFolder>()
+ .Where(i => string.IsNullOrWhiteSpace(i.CollectionType) || string.Equals(i.CollectionType, view.ViewType, StringComparison.OrdinalIgnoreCase))
+ .Where(i => user.IsFolderGrouped(i.Id))
+ .SelectMany(i => GetTopParentIdsForQuery(i, user));
+ }
+ return new Guid[] { };
+ }
+
+ var collectionFolder = item as CollectionFolder;
+ if (collectionFolder != null)
+ {
+ return collectionFolder.PhysicalFolderIds;
+ }
+
+ var topParent = item.GetTopParent();
+ if (topParent != null)
+ {
+ return new[] { topParent.Id };
+ }
+ return new Guid[] { };
+ }
+
+ /// <summary>
+ /// Gets the intros.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>IEnumerable{System.String}.</returns>
+ public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user)
+ {
+ var tasks = IntroProviders
+ .OrderBy(i => i.GetType().Name.IndexOf("Default", StringComparison.OrdinalIgnoreCase) == -1 ? 0 : 1)
+ .Take(1)
+ .Select(i => GetIntros(i, item, user));
+
+ var items = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ return items
+ .SelectMany(i => i.ToArray())
+ .Select(ResolveIntro)
+ .Where(i => i != null);
+ }
+
+ /// <summary>
+ /// Gets the intros.
+ /// </summary>
+ /// <param name="provider">The provider.</param>
+ /// <param name="item">The item.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>Task&lt;IEnumerable&lt;IntroInfo&gt;&gt;.</returns>
+ private async Task<IEnumerable<IntroInfo>> GetIntros(IIntroProvider provider, BaseItem item, User user)
+ {
+ try
+ {
+ return await provider.GetIntros(item, user).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting intros", ex);
+
+ return new List<IntroInfo>();
+ }
+ }
+
+ /// <summary>
+ /// Gets all intro files.
+ /// </summary>
+ /// <returns>IEnumerable{System.String}.</returns>
+ public IEnumerable<string> GetAllIntroFiles()
+ {
+ return IntroProviders.SelectMany(i =>
+ {
+ try
+ {
+ return i.GetAllIntroFiles().ToList();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting intro files", ex);
+
+ return new List<string>();
+ }
+ });
+ }
+
+ /// <summary>
+ /// Resolves the intro.
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <returns>Video.</returns>
+ private Video ResolveIntro(IntroInfo info)
+ {
+ Video video = null;
+
+ if (info.ItemId.HasValue)
+ {
+ // Get an existing item by Id
+ video = GetItemById(info.ItemId.Value) as Video;
+
+ if (video == null)
+ {
+ _logger.Error("Unable to locate item with Id {0}.", info.ItemId.Value);
+ }
+ }
+ else if (!string.IsNullOrEmpty(info.Path))
+ {
+ try
+ {
+ // Try to resolve the path into a video
+ video = ResolvePath(_fileSystem.GetFileSystemInfo(info.Path)) as Video;
+
+ if (video == null)
+ {
+ _logger.Error("Intro resolver returned null for {0}.", info.Path);
+ }
+ else
+ {
+ // Pull the saved db item that will include metadata
+ var dbItem = GetItemById(video.Id) as Video;
+
+ if (dbItem != null)
+ {
+ video = dbItem;
+ }
+ else
+ {
+ return null;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error resolving path {0}.", ex, info.Path);
+ }
+ }
+ else
+ {
+ _logger.Error("IntroProvider returned an IntroInfo with null Path and ItemId.");
+ }
+
+ return video;
+ }
+
+ /// <summary>
+ /// Sorts the specified sort by.
+ /// </summary>
+ /// <param name="items">The items.</param>
+ /// <param name="user">The user.</param>
+ /// <param name="sortBy">The sort by.</param>
+ /// <param name="sortOrder">The sort order.</param>
+ /// <returns>IEnumerable{BaseItem}.</returns>
+ public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<string> sortBy, SortOrder sortOrder)
+ {
+ var isFirst = true;
+
+ IOrderedEnumerable<BaseItem> orderedItems = null;
+
+ foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c != null))
+ {
+ if (isFirst)
+ {
+ orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, orderBy) : items.OrderBy(i => i, orderBy);
+ }
+ else
+ {
+ orderedItems = sortOrder == SortOrder.Descending ? orderedItems.ThenByDescending(i => i, orderBy) : orderedItems.ThenBy(i => i, orderBy);
+ }
+
+ isFirst = false;
+ }
+
+ return orderedItems ?? items;
+ }
+
+ /// <summary>
+ /// Gets the comparer.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>IBaseItemComparer.</returns>
+ private IBaseItemComparer GetComparer(string name, User user)
+ {
+ var comparer = Comparers.FirstOrDefault(c => string.Equals(name, c.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (comparer != null)
+ {
+ // If it requires a user, create a new one, and assign the user
+ if (comparer is IUserBaseItemComparer)
+ {
+ var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType());
+
+ userComparer.User = user;
+ userComparer.UserManager = _userManager;
+ userComparer.UserDataRepository = _userDataRepository;
+
+ return userComparer;
+ }
+ }
+
+ return comparer;
+ }
+
+ /// <summary>
+ /// Creates the item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task CreateItem(BaseItem item, CancellationToken cancellationToken)
+ {
+ return CreateItems(new[] { item }, cancellationToken);
+ }
+
+ /// <summary>
+ /// Creates the items.
+ /// </summary>
+ /// <param name="items">The items.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task CreateItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken)
+ {
+ var list = items.ToList();
+
+ await ItemRepository.SaveItems(list, cancellationToken).ConfigureAwait(false);
+
+ foreach (var item in list)
+ {
+ RegisterItem(item);
+ }
+
+ if (ItemAdded != null)
+ {
+ foreach (var item in list)
+ {
+ try
+ {
+ ItemAdded(this, new ItemChangeEventArgs { Item = item });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in ItemAdded event handler", ex);
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Updates the item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="updateReason">The update reason.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task UpdateItem(BaseItem item, ItemUpdateType updateReason, CancellationToken cancellationToken)
+ {
+ var locationType = item.LocationType;
+ if (locationType != LocationType.Remote && locationType != LocationType.Virtual)
+ {
+ await _providerManagerFactory().SaveMetadata(item, updateReason).ConfigureAwait(false);
+ }
+
+ item.DateLastSaved = DateTime.UtcNow;
+
+ var logName = item.LocationType == LocationType.Remote ? item.Name ?? item.Path : item.Path ?? item.Name;
+ _logger.Debug("Saving {0} to database.", logName);
+
+ await ItemRepository.SaveItem(item, cancellationToken).ConfigureAwait(false);
+
+ RegisterItem(item);
+
+ if (ItemUpdated != null)
+ {
+ try
+ {
+ ItemUpdated(this, new ItemChangeEventArgs
+ {
+ Item = item,
+ UpdateReason = updateReason
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in ItemUpdated event handler", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Reports the item removed.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ public void ReportItemRemoved(BaseItem item)
+ {
+ if (ItemRemoved != null)
+ {
+ try
+ {
+ ItemRemoved(this, new ItemChangeEventArgs { Item = item });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in ItemRemoved event handler", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Retrieves the item.
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <returns>BaseItem.</returns>
+ public BaseItem RetrieveItem(Guid id)
+ {
+ return ItemRepository.RetrieveItem(id);
+ }
+
+ public IEnumerable<Folder> GetCollectionFolders(BaseItem item)
+ {
+ while (!(item.GetParent() is AggregateFolder) && item.GetParent() != null)
+ {
+ item = item.GetParent();
+ }
+
+ if (item == null)
+ {
+ return new List<Folder>();
+ }
+
+ return GetUserRootFolder().Children
+ .OfType<Folder>()
+ .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path, StringComparer.OrdinalIgnoreCase));
+ }
+
+ public LibraryOptions GetLibraryOptions(BaseItem item)
+ {
+ var collectionFolder = item as CollectionFolder;
+ if (collectionFolder == null)
+ {
+ collectionFolder = GetCollectionFolders(item)
+ .OfType<CollectionFolder>()
+ .FirstOrDefault();
+ }
+
+ var options = collectionFolder == null ? new LibraryOptions() : collectionFolder.GetLibraryOptions();
+
+ if (options.SchemaVersion < 3)
+ {
+ options.SaveLocalMetadata = ConfigurationManager.Configuration.SaveLocalMeta;
+ options.EnableInternetProviders = ConfigurationManager.Configuration.EnableInternetProviders;
+ }
+
+ if (options.SchemaVersion < 2)
+ {
+ var chapterOptions = ConfigurationManager.GetConfiguration<ChapterOptions>("chapters");
+ options.ExtractChapterImagesDuringLibraryScan = chapterOptions.ExtractDuringLibraryScan;
+
+ if (collectionFolder != null)
+ {
+ if (string.Equals(collectionFolder.CollectionType, "movies", StringComparison.OrdinalIgnoreCase))
+ {
+ options.EnableChapterImageExtraction = chapterOptions.EnableMovieChapterImageExtraction;
+ }
+ else if (string.Equals(collectionFolder.CollectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+ {
+ options.EnableChapterImageExtraction = chapterOptions.EnableEpisodeChapterImageExtraction;
+ }
+ }
+ }
+
+ return options;
+ }
+
+ public string GetContentType(BaseItem item)
+ {
+ string configuredContentType = GetConfiguredContentType(item, false);
+ if (!string.IsNullOrWhiteSpace(configuredContentType))
+ {
+ return configuredContentType;
+ }
+ configuredContentType = GetConfiguredContentType(item, true);
+ if (!string.IsNullOrWhiteSpace(configuredContentType))
+ {
+ return configuredContentType;
+ }
+ return GetInheritedContentType(item);
+ }
+
+ public string GetInheritedContentType(BaseItem item)
+ {
+ var type = GetTopFolderContentType(item);
+
+ if (!string.IsNullOrWhiteSpace(type))
+ {
+ return type;
+ }
+
+ return item.GetParents()
+ .Select(GetConfiguredContentType)
+ .LastOrDefault(i => !string.IsNullOrWhiteSpace(i));
+ }
+
+ public string GetConfiguredContentType(BaseItem item)
+ {
+ return GetConfiguredContentType(item, false);
+ }
+
+ public string GetConfiguredContentType(string path)
+ {
+ return GetContentTypeOverride(path, false);
+ }
+
+ public string GetConfiguredContentType(BaseItem item, bool inheritConfiguredPath)
+ {
+ ICollectionFolder collectionFolder = item as ICollectionFolder;
+ if (collectionFolder != null)
+ {
+ return collectionFolder.CollectionType;
+ }
+ return GetContentTypeOverride(item.ContainingFolderPath, inheritConfiguredPath);
+ }
+
+ private string GetContentTypeOverride(string path, bool inherit)
+ {
+ var nameValuePair = ConfigurationManager.Configuration.ContentTypes.FirstOrDefault(i => string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase) || (inherit && !string.IsNullOrWhiteSpace(i.Name) && _fileSystem.ContainsSubPath(i.Name, path)));
+ if (nameValuePair != null)
+ {
+ return nameValuePair.Value;
+ }
+ return null;
+ }
+
+ private string GetTopFolderContentType(BaseItem item)
+ {
+ if (item == null)
+ {
+ return null;
+ }
+
+ while (!(item.GetParent() is AggregateFolder) && item.GetParent() != null)
+ {
+ item = item.GetParent();
+ }
+
+ return GetUserRootFolder().Children
+ .OfType<ICollectionFolder>()
+ .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path))
+ .Select(i => i.CollectionType)
+ .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
+ }
+
+ private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24);
+ //private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromMinutes(1);
+
+ public Task<UserView> GetNamedView(User user,
+ string name,
+ string viewType,
+ string sortName,
+ CancellationToken cancellationToken)
+ {
+ return GetNamedView(user, name, null, viewType, sortName, cancellationToken);
+ }
+
+ public async Task<UserView> GetNamedView(string name,
+ string viewType,
+ string sortName,
+ CancellationToken cancellationToken)
+ {
+ var path = Path.Combine(ConfigurationManager.ApplicationPaths.ItemsByNamePath, "views");
+
+ path = Path.Combine(path, _fileSystem.GetValidFilename(viewType));
+
+ var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView));
+
+ var item = GetItemById(id) as UserView;
+
+ var refresh = false;
+
+ if (item == null || !string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ _fileSystem.CreateDirectory(path);
+
+ item = new UserView
+ {
+ Path = path,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ Name = name,
+ ViewType = viewType,
+ ForcedSortName = sortName
+ };
+
+ await CreateItem(item, cancellationToken).ConfigureAwait(false);
+
+ refresh = true;
+ }
+
+ if (!refresh)
+ {
+ refresh = DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+ }
+
+ if (!refresh && item.DisplayParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(item.DisplayParentId);
+ refresh = displayParent != null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ }
+
+ if (refresh)
+ {
+ await item.UpdateToRepository(ItemUpdateType.MetadataImport, CancellationToken.None).ConfigureAwait(false);
+ _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ // Not sure why this is necessary but need to figure it out
+ // View images are not getting utilized without this
+ ForceSave = true
+ });
+ }
+
+ return item;
+ }
+
+ public async Task<UserView> GetNamedView(User user,
+ string name,
+ string parentId,
+ string viewType,
+ string sortName,
+ CancellationToken cancellationToken)
+ {
+ var idValues = "38_namedview_" + name + user.Id.ToString("N") + (parentId ?? string.Empty) + (viewType ?? string.Empty);
+
+ var id = GetNewItemId(idValues, typeof(UserView));
+
+ var path = Path.Combine(ConfigurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N"));
+
+ var item = GetItemById(id) as UserView;
+
+ var isNew = false;
+
+ if (item == null)
+ {
+ _fileSystem.CreateDirectory(path);
+
+ item = new UserView
+ {
+ Path = path,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ Name = name,
+ ViewType = viewType,
+ ForcedSortName = sortName,
+ UserId = user.Id
+ };
+
+ if (!string.IsNullOrWhiteSpace(parentId))
+ {
+ item.DisplayParentId = new Guid(parentId);
+ }
+
+ await CreateItem(item, cancellationToken).ConfigureAwait(false);
+
+ isNew = true;
+ }
+
+ var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+
+ if (!refresh && item.DisplayParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(item.DisplayParentId);
+ refresh = displayParent != null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ }
+
+ if (refresh)
+ {
+ _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ // Need to force save to increment DateLastSaved
+ ForceSave = true
+ });
+ }
+
+ return item;
+ }
+
+ public async Task<UserView> GetShadowView(BaseItem parent,
+ string viewType,
+ string sortName,
+ CancellationToken cancellationToken)
+ {
+ if (parent == null)
+ {
+ throw new ArgumentNullException("parent");
+ }
+
+ var name = parent.Name;
+ var parentId = parent.Id;
+
+ var idValues = "38_namedview_" + name + parentId + (viewType ?? string.Empty);
+
+ var id = GetNewItemId(idValues, typeof(UserView));
+
+ var path = parent.Path;
+
+ var item = GetItemById(id) as UserView;
+
+ var isNew = false;
+
+ if (item == null)
+ {
+ _fileSystem.CreateDirectory(path);
+
+ item = new UserView
+ {
+ Path = path,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ Name = name,
+ ViewType = viewType,
+ ForcedSortName = sortName
+ };
+
+ item.DisplayParentId = parentId;
+
+ await CreateItem(item, cancellationToken).ConfigureAwait(false);
+
+ isNew = true;
+ }
+
+ var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+
+ if (!refresh && item.DisplayParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(item.DisplayParentId);
+ refresh = displayParent != null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ }
+
+ if (refresh)
+ {
+ _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ // Need to force save to increment DateLastSaved
+ ForceSave = true
+ });
+ }
+
+ return item;
+ }
+
+ public async Task<UserView> GetNamedView(string name,
+ string parentId,
+ string viewType,
+ string sortName,
+ string uniqueId,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+
+ var idValues = "37_namedview_" + name + (parentId ?? string.Empty) + (viewType ?? string.Empty);
+ if (!string.IsNullOrWhiteSpace(uniqueId))
+ {
+ idValues += uniqueId;
+ }
+
+ var id = GetNewItemId(idValues, typeof(UserView));
+
+ var path = Path.Combine(ConfigurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N"));
+
+ var item = GetItemById(id) as UserView;
+
+ var isNew = false;
+
+ if (item == null)
+ {
+ _fileSystem.CreateDirectory(path);
+
+ item = new UserView
+ {
+ Path = path,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ Name = name,
+ ViewType = viewType,
+ ForcedSortName = sortName
+ };
+
+ if (!string.IsNullOrWhiteSpace(parentId))
+ {
+ item.DisplayParentId = new Guid(parentId);
+ }
+
+ await CreateItem(item, cancellationToken).ConfigureAwait(false);
+
+ isNew = true;
+ }
+
+ if (!string.Equals(viewType, item.ViewType, StringComparison.OrdinalIgnoreCase))
+ {
+ item.ViewType = viewType;
+ await item.UpdateToRepository(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+
+ var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+
+ if (!refresh && item.DisplayParentId != Guid.Empty)
+ {
+ var displayParent = GetItemById(item.DisplayParentId);
+ refresh = displayParent != null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ }
+
+ if (refresh)
+ {
+ _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ // Need to force save to increment DateLastSaved
+ ForceSave = true
+ });
+ }
+
+ return item;
+ }
+
+ public bool IsVideoFile(string path, LibraryOptions libraryOptions)
+ {
+ var resolver = new VideoResolver(GetNamingOptions(libraryOptions), new NullLogger());
+ return resolver.IsVideoFile(path);
+ }
+
+ public bool IsVideoFile(string path)
+ {
+ return IsVideoFile(path, new LibraryOptions());
+ }
+
+ public bool IsAudioFile(string path, LibraryOptions libraryOptions)
+ {
+ var parser = new AudioFileParser(GetNamingOptions(libraryOptions));
+ return parser.IsAudioFile(path);
+ }
+
+ public bool IsAudioFile(string path)
+ {
+ return IsAudioFile(path, new LibraryOptions());
+ }
+
+ public int? GetSeasonNumberFromPath(string path)
+ {
+ return new SeasonPathParser(GetNamingOptions(), new RegexProvider()).Parse(path, true, true).SeasonNumber;
+ }
+
+ public bool FillMissingEpisodeNumbersFromPath(Episode episode)
+ {
+ var resolver = new EpisodeResolver(GetNamingOptions(),
+ new NullLogger());
+
+ var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd ||
+ episode.VideoType == VideoType.HdDvd;
+
+ var locationType = episode.LocationType;
+
+ var episodeInfo = locationType == LocationType.FileSystem || locationType == LocationType.Offline ?
+ resolver.Resolve(episode.Path, isFolder) :
+ new MediaBrowser.Naming.TV.EpisodeInfo();
+
+ if (episodeInfo == null)
+ {
+ episodeInfo = new MediaBrowser.Naming.TV.EpisodeInfo();
+ }
+
+ var changed = false;
+
+ if (episodeInfo.IsByDate)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ episode.IndexNumber = null;
+ changed = true;
+ }
+
+ if (episode.IndexNumberEnd.HasValue)
+ {
+ episode.IndexNumberEnd = null;
+ changed = true;
+ }
+
+ if (!episode.PremiereDate.HasValue)
+ {
+ if (episodeInfo.Year.HasValue && episodeInfo.Month.HasValue && episodeInfo.Day.HasValue)
+ {
+ episode.PremiereDate = new DateTime(episodeInfo.Year.Value, episodeInfo.Month.Value, episodeInfo.Day.Value).ToUniversalTime();
+ }
+
+ if (episode.PremiereDate.HasValue)
+ {
+ changed = true;
+ }
+ }
+
+ if (!episode.ProductionYear.HasValue)
+ {
+ episode.ProductionYear = episodeInfo.Year;
+
+ if (episode.ProductionYear.HasValue)
+ {
+ changed = true;
+ }
+ }
+
+ if (!episode.ParentIndexNumber.HasValue)
+ {
+ var season = episode.Season;
+
+ if (season != null)
+ {
+ episode.ParentIndexNumber = season.IndexNumber;
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ changed = true;
+ }
+ }
+ }
+ else
+ {
+ if (!episode.IndexNumber.HasValue)
+ {
+ episode.IndexNumber = episodeInfo.EpisodeNumber;
+
+ if (episode.IndexNumber.HasValue)
+ {
+ changed = true;
+ }
+ }
+
+ if (!episode.IndexNumberEnd.HasValue)
+ {
+ episode.IndexNumberEnd = episodeInfo.EndingEpsiodeNumber;
+
+ if (episode.IndexNumberEnd.HasValue)
+ {
+ changed = true;
+ }
+ }
+
+ if (!episode.ParentIndexNumber.HasValue)
+ {
+ episode.ParentIndexNumber = episodeInfo.SeasonNumber;
+
+ if (!episode.ParentIndexNumber.HasValue)
+ {
+ var season = episode.Season;
+
+ if (season != null)
+ {
+ episode.ParentIndexNumber = season.IndexNumber;
+ }
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ changed = true;
+ }
+ }
+ }
+
+ return changed;
+ }
+
+ public NamingOptions GetNamingOptions()
+ {
+ return GetNamingOptions(new LibraryOptions());
+ }
+
+ public NamingOptions GetNamingOptions(LibraryOptions libraryOptions)
+ {
+ var options = new ExtendedNamingOptions();
+
+ // These cause apps to have problems
+ options.AudioFileExtensions.Remove(".m3u");
+ options.AudioFileExtensions.Remove(".wpl");
+
+ if (!libraryOptions.EnableArchiveMediaFiles)
+ {
+ options.AudioFileExtensions.Remove(".rar");
+ options.AudioFileExtensions.Remove(".zip");
+ }
+
+ if (!libraryOptions.EnableArchiveMediaFiles)
+ {
+ options.VideoFileExtensions.Remove(".rar");
+ options.VideoFileExtensions.Remove(".zip");
+ }
+
+ return options;
+ }
+
+ public ItemLookupInfo ParseName(string name)
+ {
+ var resolver = new VideoResolver(GetNamingOptions(), new NullLogger());
+
+ var result = resolver.CleanDateTime(name);
+ var cleanName = resolver.CleanString(result.Name);
+
+ return new ItemLookupInfo
+ {
+ Name = cleanName.Name,
+ Year = result.Year
+ };
+ }
+
+ public IEnumerable<Video> FindTrailers(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
+ {
+ var files = owner.DetectIsInMixedFolder() ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory)
+ .Where(i => string.Equals(i.Name, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase))
+ .SelectMany(i => _fileSystem.GetFiles(i.FullName, false))
+ .ToList();
+
+ var videoListResolver = new VideoListResolver(GetNamingOptions(), new NullLogger());
+
+ var videos = videoListResolver.Resolve(fileSystemChildren);
+
+ var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase));
+
+ if (currentVideo != null)
+ {
+ files.AddRange(currentVideo.Extras.Where(i => string.Equals(i.ExtraType, "trailer", StringComparison.OrdinalIgnoreCase)).Select(i => _fileSystem.GetFileInfo(i.Path)));
+ }
+
+ var resolvers = new IItemResolver[]
+ {
+ new GenericVideoResolver<Trailer>(this)
+ };
+
+ return ResolvePaths(files, directoryService, null, new LibraryOptions(), null, resolvers)
+ .OfType<Trailer>()
+ .Select(video =>
+ {
+ // Try to retrieve it from the db. If we don't find it, use the resolved version
+ var dbItem = GetItemById(video.Id) as Trailer;
+
+ if (dbItem != null)
+ {
+ video = dbItem;
+ }
+
+ video.ExtraType = ExtraType.Trailer;
+ video.TrailerTypes = new List<TrailerType> { TrailerType.LocalTrailer };
+
+ return video;
+
+ // Sort them so that the list can be easily compared for changes
+ }).OrderBy(i => i.Path).ToList();
+ }
+
+ private static readonly string[] ExtrasSubfolderNames = new[] { "extras", "specials", "shorts", "scenes", "featurettes", "behind the scenes", "deleted scenes" };
+
+ public IEnumerable<Video> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
+ {
+ var files = fileSystemChildren.Where(i => i.IsDirectory)
+ .Where(i => ExtrasSubfolderNames.Contains(i.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+ .SelectMany(i => _fileSystem.GetFiles(i.FullName, false))
+ .ToList();
+
+ var videoListResolver = new VideoListResolver(GetNamingOptions(), new NullLogger());
+
+ var videos = videoListResolver.Resolve(fileSystemChildren);
+
+ var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase));
+
+ if (currentVideo != null)
+ {
+ files.AddRange(currentVideo.Extras.Where(i => !string.Equals(i.ExtraType, "trailer", StringComparison.OrdinalIgnoreCase)).Select(i => _fileSystem.GetFileInfo(i.Path)));
+ }
+
+ return ResolvePaths(files, directoryService, null, new LibraryOptions(), null)
+ .OfType<Video>()
+ .Select(video =>
+ {
+ // Try to retrieve it from the db. If we don't find it, use the resolved version
+ var dbItem = GetItemById(video.Id) as Video;
+
+ if (dbItem != null)
+ {
+ video = dbItem;
+ }
+
+ SetExtraTypeFromFilename(video);
+
+ return video;
+
+ // Sort them so that the list can be easily compared for changes
+ }).OrderBy(i => i.Path).ToList();
+ }
+
+ public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem)
+ {
+ if (ownerItem != null)
+ {
+ var libraryOptions = GetLibraryOptions(ownerItem);
+ if (libraryOptions != null)
+ {
+ foreach (var pathInfo in libraryOptions.PathInfos)
+ {
+ if (string.IsNullOrWhiteSpace(pathInfo.NetworkPath))
+ {
+ continue;
+ }
+
+ var substitutionResult = SubstitutePathInternal(path, pathInfo.Path, pathInfo.NetworkPath);
+ if (substitutionResult.Item2)
+ {
+ return substitutionResult.Item1;
+ }
+ }
+ }
+ }
+
+ var metadataPath = ConfigurationManager.Configuration.MetadataPath;
+ var metadataNetworkPath = ConfigurationManager.Configuration.MetadataNetworkPath;
+
+ if (!string.IsNullOrWhiteSpace(metadataPath) && !string.IsNullOrWhiteSpace(metadataNetworkPath))
+ {
+ var metadataSubstitutionResult = SubstitutePathInternal(path, metadataPath, metadataNetworkPath);
+ if (metadataSubstitutionResult.Item2)
+ {
+ return metadataSubstitutionResult.Item1;
+ }
+ }
+
+ foreach (var map in ConfigurationManager.Configuration.PathSubstitutions)
+ {
+ var substitutionResult = SubstitutePathInternal(path, map.From, map.To);
+ if (substitutionResult.Item2)
+ {
+ return substitutionResult.Item1;
+ }
+ }
+
+ return path;
+ }
+
+ public string SubstitutePath(string path, string from, string to)
+ {
+ return SubstitutePathInternal(path, from, to).Item1;
+ }
+
+ private Tuple<string, bool> SubstitutePathInternal(string path, string from, string to)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+ if (string.IsNullOrWhiteSpace(from))
+ {
+ throw new ArgumentNullException("from");
+ }
+ if (string.IsNullOrWhiteSpace(to))
+ {
+ throw new ArgumentNullException("to");
+ }
+
+ from = from.Trim();
+ to = to.Trim();
+
+ var newPath = path.Replace(from, to, StringComparison.OrdinalIgnoreCase);
+ var changed = false;
+
+ if (!string.Equals(newPath, path))
+ {
+ if (to.IndexOf('/') != -1)
+ {
+ newPath = newPath.Replace('\\', '/');
+ }
+ else
+ {
+ newPath = newPath.Replace('/', '\\');
+ }
+
+ changed = true;
+ }
+
+ return new Tuple<string, bool>(newPath, changed);
+ }
+
+ private void SetExtraTypeFromFilename(Video item)
+ {
+ var resolver = new ExtraResolver(GetNamingOptions(), new NullLogger(), new RegexProvider());
+
+ var result = resolver.GetExtraInfo(item.Path);
+
+ if (string.Equals(result.ExtraType, "deletedscene", StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExtraType = ExtraType.DeletedScene;
+ }
+ else if (string.Equals(result.ExtraType, "behindthescenes", StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExtraType = ExtraType.BehindTheScenes;
+ }
+ else if (string.Equals(result.ExtraType, "interview", StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExtraType = ExtraType.Interview;
+ }
+ else if (string.Equals(result.ExtraType, "scene", StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExtraType = ExtraType.Scene;
+ }
+ else if (string.Equals(result.ExtraType, "sample", StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExtraType = ExtraType.Sample;
+ }
+ else
+ {
+ item.ExtraType = ExtraType.Clip;
+ }
+ }
+
+ public List<PersonInfo> GetPeople(InternalPeopleQuery query)
+ {
+ return ItemRepository.GetPeople(query);
+ }
+
+ public List<PersonInfo> GetPeople(BaseItem item)
+ {
+ if (item.SupportsPeople)
+ {
+ var people = GetPeople(new InternalPeopleQuery
+ {
+ ItemId = item.Id
+ });
+
+ if (people.Count > 0)
+ {
+ return people;
+ }
+ }
+
+ return new List<PersonInfo>();
+ }
+
+ public List<Person> GetPeopleItems(InternalPeopleQuery query)
+ {
+ return ItemRepository.GetPeopleNames(query).Select(i =>
+ {
+ try
+ {
+ return GetPerson(i);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting person", ex);
+ return null;
+ }
+
+ }).Where(i => i != null).ToList();
+ }
+
+ public List<string> GetPeopleNames(InternalPeopleQuery query)
+ {
+ return ItemRepository.GetPeopleNames(query);
+ }
+
+ public Task UpdatePeople(BaseItem item, List<PersonInfo> people)
+ {
+ if (!item.SupportsPeople)
+ {
+ return Task.FromResult(true);
+ }
+
+ return ItemRepository.UpdatePeople(item.Id, people);
+ }
+
+ private readonly SemaphoreSlim _dynamicImageResourcePool = new SemaphoreSlim(1, 1);
+ public async Task<ItemImageInfo> ConvertImageToLocal(IHasImages item, ItemImageInfo image, int imageIndex)
+ {
+ foreach (var url in image.Path.Split('|'))
+ {
+ try
+ {
+ _logger.Debug("ConvertImageToLocal item {0} - image url: {1}", item.Id, url);
+
+ await _providerManagerFactory().SaveImage(item, url, _dynamicImageResourcePool, image.Type, imageIndex, CancellationToken.None).ConfigureAwait(false);
+
+ var newImage = item.GetImageInfo(image.Type, imageIndex);
+
+ if (newImage != null)
+ {
+ newImage.IsPlaceholder = image.IsPlaceholder;
+ }
+
+ await item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+ return item.GetImageInfo(image.Type, imageIndex);
+ }
+ catch (HttpException ex)
+ {
+ if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound)
+ {
+ continue;
+ }
+ throw;
+ }
+ }
+
+ // Remove this image to prevent it from retrying over and over
+ item.RemoveImage(image);
+ await item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+ throw new InvalidOperationException();
+ }
+
+ public void AddVirtualFolder(string name, string collectionType, LibraryOptions options, bool refreshLibrary)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+
+ name = _fileSystem.GetValidFilename(name);
+
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+
+ var virtualFolderPath = Path.Combine(rootFolderPath, name);
+ while (_fileSystem.DirectoryExists(virtualFolderPath))
+ {
+ name += "1";
+ virtualFolderPath = Path.Combine(rootFolderPath, name);
+ }
+
+ var mediaPathInfos = options.PathInfos;
+ if (mediaPathInfos != null)
+ {
+ var invalidpath = mediaPathInfos.FirstOrDefault(i => !_fileSystem.DirectoryExists(i.Path));
+ if (invalidpath != null)
+ {
+ throw new ArgumentException("The specified path does not exist: " + invalidpath.Path + ".");
+ }
+ }
+
+ _libraryMonitorFactory().Stop();
+
+ try
+ {
+ _fileSystem.CreateDirectory(virtualFolderPath);
+
+ if (!string.IsNullOrEmpty(collectionType))
+ {
+ var path = Path.Combine(virtualFolderPath, collectionType + ".collection");
+
+ _fileSystem.WriteAllBytes(path, new byte[] { });
+ }
+
+ CollectionFolder.SaveLibraryOptions(virtualFolderPath, options);
+
+ if (mediaPathInfos != null)
+ {
+ foreach (var path in mediaPathInfos)
+ {
+ AddMediaPathInternal(name, path, false);
+ }
+ }
+ }
+ finally
+ {
+ Task.Run(() =>
+ {
+ // No need to start if scanning the library because it will handle it
+ if (refreshLibrary)
+ {
+ ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
+ }
+ else
+ {
+ // Need to add a delay here or directory watchers may still pick up the changes
+ var task = Task.Delay(1000);
+ // Have to block here to allow exceptions to bubble
+ Task.WaitAll(task);
+
+ _libraryMonitorFactory().Start();
+ }
+ });
+ }
+ }
+
+ private bool ValidateNetworkPath(string path)
+ {
+ //if (Environment.OSVersion.Platform == PlatformID.Win32NT)
+ //{
+ // // We can't validate protocol-based paths, so just allow them
+ // if (path.IndexOf("://", StringComparison.OrdinalIgnoreCase) == -1)
+ // {
+ // return _fileSystem.DirectoryExists(path);
+ // }
+ //}
+
+ // Without native support for unc, we cannot validate this when running under mono
+ return true;
+ }
+
+ private const string ShortcutFileExtension = ".mblink";
+ public void AddMediaPath(string virtualFolderName, MediaPathInfo pathInfo)
+ {
+ AddMediaPathInternal(virtualFolderName, pathInfo, true);
+ }
+
+ private void AddMediaPathInternal(string virtualFolderName, MediaPathInfo pathInfo, bool saveLibraryOptions)
+ {
+ if (pathInfo == null)
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ var path = pathInfo.Path;
+
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ if (!_fileSystem.DirectoryExists(path))
+ {
+ throw new FileNotFoundException("The path does not exist.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(pathInfo.NetworkPath) && !ValidateNetworkPath(pathInfo.NetworkPath))
+ {
+ throw new FileNotFoundException("The network path does not exist.");
+ }
+
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+ var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+
+ var shortcutFilename = _fileSystem.GetFileNameWithoutExtension(path);
+
+ var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
+
+ while (_fileSystem.FileExists(lnk))
+ {
+ shortcutFilename += "1";
+ lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
+ }
+
+ _fileSystem.CreateShortcut(lnk, path);
+
+ RemoveContentTypeOverrides(path);
+
+ if (saveLibraryOptions)
+ {
+ var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
+
+ var list = libraryOptions.PathInfos.ToList();
+ list.Add(pathInfo);
+ libraryOptions.PathInfos = list.ToArray();
+
+ SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions);
+
+ CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
+ }
+ }
+
+ public void UpdateMediaPath(string virtualFolderName, MediaPathInfo pathInfo)
+ {
+ if (pathInfo == null)
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ if (!string.IsNullOrWhiteSpace(pathInfo.NetworkPath) && !ValidateNetworkPath(pathInfo.NetworkPath))
+ {
+ throw new FileNotFoundException("The network path does not exist.");
+ }
+
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+ var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+
+ var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
+
+ SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions);
+
+ var list = libraryOptions.PathInfos.ToList();
+ foreach (var originalPathInfo in list)
+ {
+ if (string.Equals(pathInfo.Path, originalPathInfo.Path, StringComparison.Ordinal))
+ {
+ originalPathInfo.NetworkPath = pathInfo.NetworkPath;
+ break;
+ }
+ }
+
+ libraryOptions.PathInfos = list.ToArray();
+
+ CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
+ }
+
+ private void SyncLibraryOptionsToLocations(string virtualFolderPath, LibraryOptions options)
+ {
+ var topLibraryFolders = GetUserRootFolder().Children.ToList();
+ var info = GetVirtualFolderInfo(virtualFolderPath, topLibraryFolders);
+
+ if (info.Locations.Count > 0 && info.Locations.Count != options.PathInfos.Length)
+ {
+ var list = options.PathInfos.ToList();
+
+ foreach (var location in info.Locations)
+ {
+ if (!list.Any(i => string.Equals(i.Path, location, StringComparison.Ordinal)))
+ {
+ list.Add(new MediaPathInfo
+ {
+ Path = location
+ });
+ }
+ }
+
+ options.PathInfos = list.ToArray();
+ }
+ }
+
+ public void RemoveVirtualFolder(string name, bool refreshLibrary)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+
+ var path = Path.Combine(rootFolderPath, name);
+
+ if (!_fileSystem.DirectoryExists(path))
+ {
+ throw new FileNotFoundException("The media folder does not exist");
+ }
+
+ _libraryMonitorFactory().Stop();
+
+ try
+ {
+ _fileSystem.DeleteDirectory(path, true);
+ }
+ finally
+ {
+ Task.Run(() =>
+ {
+ // No need to start if scanning the library because it will handle it
+ if (refreshLibrary)
+ {
+ ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
+ }
+ else
+ {
+ // Need to add a delay here or directory watchers may still pick up the changes
+ var task = Task.Delay(1000);
+ // Have to block here to allow exceptions to bubble
+ Task.WaitAll(task);
+
+ _libraryMonitorFactory().Start();
+ }
+ });
+ }
+ }
+
+ private void RemoveContentTypeOverrides(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ var removeList = new List<NameValuePair>();
+
+ foreach (var contentType in ConfigurationManager.Configuration.ContentTypes)
+ {
+ if (string.Equals(path, contentType.Name, StringComparison.OrdinalIgnoreCase)
+ || _fileSystem.ContainsSubPath(path, contentType.Name))
+ {
+ removeList.Add(contentType);
+ }
+ }
+
+ if (removeList.Count > 0)
+ {
+ ConfigurationManager.Configuration.ContentTypes = ConfigurationManager.Configuration.ContentTypes
+ .Except(removeList)
+ .ToArray();
+
+ ConfigurationManager.SaveConfiguration();
+ }
+ }
+
+ public void RemoveMediaPath(string virtualFolderName, string mediaPath)
+ {
+ if (string.IsNullOrWhiteSpace(mediaPath))
+ {
+ throw new ArgumentNullException("mediaPath");
+ }
+
+ var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath;
+ var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+
+ if (!_fileSystem.DirectoryExists(virtualFolderPath))
+ {
+ throw new FileNotFoundException(string.Format("The media collection {0} does not exist", virtualFolderName));
+ }
+
+ var shortcut = _fileSystem.GetFilePaths(virtualFolderPath, true)
+ .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase))
+ .FirstOrDefault(f => _fileSystem.ResolveShortcut(f).Equals(mediaPath, StringComparison.OrdinalIgnoreCase));
+
+ if (!string.IsNullOrEmpty(shortcut))
+ {
+ _fileSystem.DeleteFile(shortcut);
+ }
+
+ var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
+
+ libraryOptions.PathInfos = libraryOptions
+ .PathInfos
+ .Where(i => !string.Equals(i.Path, mediaPath, StringComparison.Ordinal))
+ .ToArray();
+
+ CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Library/LocalTrailerPostScanTask.cs b/Emby.Server.Implementations/Library/LocalTrailerPostScanTask.cs
new file mode 100644
index 000000000..7424ed5e5
--- /dev/null
+++ b/Emby.Server.Implementations/Library/LocalTrailerPostScanTask.cs
@@ -0,0 +1,102 @@
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+
+namespace Emby.Server.Implementations.Library
+{
+ public class LocalTrailerPostScanTask : ILibraryPostScanTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IChannelManager _channelManager;
+
+ public LocalTrailerPostScanTask(ILibraryManager libraryManager, IChannelManager channelManager)
+ {
+ _libraryManager = libraryManager;
+ _channelManager = channelManager;
+ }
+
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(BoxSet).Name, typeof(Game).Name, typeof(Movie).Name, typeof(Series).Name },
+ Recursive = true
+
+ }).OfType<IHasTrailers>().ToList();
+
+ var trailerTypes = Enum.GetNames(typeof(TrailerType))
+ .Select(i => (TrailerType)Enum.Parse(typeof(TrailerType), i, true))
+ .Except(new[] { TrailerType.LocalTrailer })
+ .ToArray();
+
+ var trailers = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Trailer).Name },
+ TrailerTypes = trailerTypes,
+ Recursive = true
+
+ }).ToArray();
+
+ var numComplete = 0;
+
+ foreach (var item in items)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await AssignTrailers(item, trailers).ConfigureAwait(false);
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= items.Count;
+ progress.Report(percent * 100);
+ }
+
+ progress.Report(100);
+ }
+
+ private async Task AssignTrailers(IHasTrailers item, BaseItem[] channelTrailers)
+ {
+ if (item is Game)
+ {
+ return;
+ }
+
+ var imdbId = item.GetProviderId(MetadataProviders.Imdb);
+ var tmdbId = item.GetProviderId(MetadataProviders.Tmdb);
+
+ var trailers = channelTrailers.Where(i =>
+ {
+ if (!string.IsNullOrWhiteSpace(imdbId) &&
+ string.Equals(imdbId, i.GetProviderId(MetadataProviders.Imdb), StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (!string.IsNullOrWhiteSpace(tmdbId) &&
+ string.Equals(tmdbId, i.GetProviderId(MetadataProviders.Tmdb), StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ return false;
+ });
+
+ var trailerIds = trailers.Select(i => i.Id)
+ .ToList();
+
+ if (!trailerIds.SequenceEqual(item.RemoteTrailerIds))
+ {
+ item.RemoteTrailerIds = trailerIds;
+
+ var baseItem = (BaseItem)item;
+ await baseItem.UpdateToRepository(ItemUpdateType.MetadataImport, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
new file mode 100644
index 000000000..93c406ebc
--- /dev/null
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -0,0 +1,651 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Library
+{
+ public class MediaSourceManager : IMediaSourceManager, IDisposable
+ {
+ private readonly IItemRepository _itemRepo;
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IFileSystem _fileSystem;
+
+ private IMediaSourceProvider[] _providers;
+ private readonly ILogger _logger;
+ private readonly IUserDataManager _userDataManager;
+ private readonly ITimerFactory _timerFactory;
+
+ public MediaSourceManager(IItemRepository itemRepo, IUserManager userManager, ILibraryManager libraryManager, ILogger logger, IJsonSerializer jsonSerializer, IFileSystem fileSystem, IUserDataManager userDataManager, ITimerFactory timerFactory)
+ {
+ _itemRepo = itemRepo;
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _jsonSerializer = jsonSerializer;
+ _fileSystem = fileSystem;
+ _userDataManager = userDataManager;
+ _timerFactory = timerFactory;
+ }
+
+ public void AddParts(IEnumerable<IMediaSourceProvider> providers)
+ {
+ _providers = providers.ToArray();
+ }
+
+ public IEnumerable<MediaStream> GetMediaStreams(MediaStreamQuery query)
+ {
+ var list = _itemRepo.GetMediaStreams(query)
+ .ToList();
+
+ foreach (var stream in list)
+ {
+ stream.SupportsExternalStream = StreamSupportsExternalStream(stream);
+ }
+
+ return list;
+ }
+
+ private bool StreamSupportsExternalStream(MediaStream stream)
+ {
+ if (stream.IsExternal)
+ {
+ return true;
+ }
+
+ if (stream.IsTextSubtitleStream)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public IEnumerable<MediaStream> GetMediaStreams(string mediaSourceId)
+ {
+ var list = GetMediaStreams(new MediaStreamQuery
+ {
+ ItemId = new Guid(mediaSourceId)
+ });
+
+ return GetMediaStreamsForItem(list);
+ }
+
+ public IEnumerable<MediaStream> GetMediaStreams(Guid itemId)
+ {
+ var list = GetMediaStreams(new MediaStreamQuery
+ {
+ ItemId = itemId
+ });
+
+ return GetMediaStreamsForItem(list);
+ }
+
+ private IEnumerable<MediaStream> GetMediaStreamsForItem(IEnumerable<MediaStream> streams)
+ {
+ var list = streams.ToList();
+
+ var subtitleStreams = list
+ .Where(i => i.Type == MediaStreamType.Subtitle)
+ .ToList();
+
+ if (subtitleStreams.Count > 0)
+ {
+ foreach (var subStream in subtitleStreams)
+ {
+ subStream.SupportsExternalStream = StreamSupportsExternalStream(subStream);
+ }
+ }
+
+ return list;
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetPlayackMediaSources(string id, string userId, bool enablePathSubstitution, string[] supportedLiveMediaTypes, CancellationToken cancellationToken)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ var hasMediaSources = (IHasMediaSources)item;
+ User user = null;
+
+ if (!string.IsNullOrWhiteSpace(userId))
+ {
+ user = _userManager.GetUserById(userId);
+ }
+
+ var mediaSources = GetStaticMediaSources(hasMediaSources, enablePathSubstitution, user);
+ var dynamicMediaSources = await GetDynamicMediaSources(hasMediaSources, cancellationToken).ConfigureAwait(false);
+
+ var list = new List<MediaSourceInfo>();
+
+ list.AddRange(mediaSources);
+
+ foreach (var source in dynamicMediaSources)
+ {
+ if (user != null)
+ {
+ SetUserProperties(hasMediaSources, source, user);
+ }
+ if (source.Protocol == MediaProtocol.File)
+ {
+ // TODO: Path substitution
+ if (!_fileSystem.FileExists(source.Path))
+ {
+ source.SupportsDirectStream = false;
+ }
+ }
+ else if (source.Protocol == MediaProtocol.Http)
+ {
+ // TODO: Allow this when the source is plain http, e.g. not HLS or Mpeg Dash
+ source.SupportsDirectStream = false;
+ }
+ else
+ {
+ source.SupportsDirectStream = false;
+ }
+
+ list.Add(source);
+ }
+
+ foreach (var source in list)
+ {
+ if (user != null)
+ {
+ if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
+ {
+ if (!user.Policy.EnableAudioPlaybackTranscoding)
+ {
+ source.SupportsTranscoding = false;
+ }
+ }
+ }
+ }
+
+ return SortMediaSources(list).Where(i => i.Type != MediaSourceType.Placeholder);
+ }
+
+ private async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var tasks = _providers.Select(i => GetDynamicMediaSources(item, i, cancellationToken));
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ return results.SelectMany(i => i.ToList());
+ }
+
+ private async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(IHasMediaSources item, IMediaSourceProvider provider, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var sources = await provider.GetMediaSources(item, cancellationToken).ConfigureAwait(false);
+ var list = sources.ToList();
+
+ foreach (var mediaSource in list)
+ {
+ SetKeyProperties(provider, mediaSource);
+ }
+
+ return list;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting media sources", ex);
+ return new List<MediaSourceInfo>();
+ }
+ }
+
+ private void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
+ {
+ var prefix = provider.GetType().FullName.GetMD5().ToString("N") + LiveStreamIdDelimeter;
+
+ if (!string.IsNullOrWhiteSpace(mediaSource.OpenToken) && !mediaSource.OpenToken.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSource.OpenToken = prefix + mediaSource.OpenToken;
+ }
+
+ if (!string.IsNullOrWhiteSpace(mediaSource.LiveStreamId) && !mediaSource.LiveStreamId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSource.LiveStreamId = prefix + mediaSource.LiveStreamId;
+ }
+ }
+
+ public async Task<MediaSourceInfo> GetMediaSource(IHasMediaSources item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrWhiteSpace(liveStreamId))
+ {
+ return await GetLiveStream(liveStreamId, cancellationToken).ConfigureAwait(false);
+ }
+ //await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ //try
+ //{
+ // var stream = _openStreams.Values.FirstOrDefault(i => string.Equals(i.MediaSource.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
+
+ // if (stream != null)
+ // {
+ // return stream.MediaSource;
+ // }
+ //}
+ //finally
+ //{
+ // _liveStreamSemaphore.Release();
+ //}
+
+ var sources = await GetPlayackMediaSources(item.Id.ToString("N"), null, enablePathSubstitution, new[] { MediaType.Audio, MediaType.Video },
+ CancellationToken.None).ConfigureAwait(false);
+
+ return sources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public IEnumerable<MediaSourceInfo> GetStaticMediaSources(IHasMediaSources item, bool enablePathSubstitution, User user = null)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ if (!(item is Video))
+ {
+ return item.GetMediaSources(enablePathSubstitution);
+ }
+
+ var sources = item.GetMediaSources(enablePathSubstitution).ToList();
+
+ if (user != null)
+ {
+ foreach (var source in sources)
+ {
+ SetUserProperties(item, source, user);
+ }
+ }
+
+ return sources;
+ }
+
+ private void SetUserProperties(IHasUserData item, MediaSourceInfo source, User user)
+ {
+ var userData = item == null ? new UserItemData() : _userDataManager.GetUserData(user, item);
+
+ var allowRememberingSelection = item == null || item.EnableRememberingTrackSelections;
+
+ SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection);
+ SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
+ }
+
+ private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
+ {
+ if (userData.SubtitleStreamIndex.HasValue && user.Configuration.RememberSubtitleSelections && user.Configuration.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection)
+ {
+ var index = userData.SubtitleStreamIndex.Value;
+ // Make sure the saved index is still valid
+ if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index))
+ {
+ source.DefaultSubtitleStreamIndex = index;
+ return;
+ }
+ }
+
+ var preferredSubs = string.IsNullOrEmpty(user.Configuration.SubtitleLanguagePreference)
+ ? new List<string>() : new List<string> { user.Configuration.SubtitleLanguagePreference };
+
+ var defaultAudioIndex = source.DefaultAudioStreamIndex;
+ var audioLangage = defaultAudioIndex == null
+ ? null
+ : source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault();
+
+ source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex(source.MediaStreams,
+ preferredSubs,
+ user.Configuration.SubtitleMode,
+ audioLangage);
+
+ MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs,
+ user.Configuration.SubtitleMode, audioLangage);
+ }
+
+ private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
+ {
+ if (userData.AudioStreamIndex.HasValue && user.Configuration.RememberAudioSelections && allowRememberingSelection)
+ {
+ var index = userData.AudioStreamIndex.Value;
+ // Make sure the saved index is still valid
+ if (source.MediaStreams.Any(i => i.Type == MediaStreamType.Audio && i.Index == index))
+ {
+ source.DefaultAudioStreamIndex = index;
+ return;
+ }
+ }
+
+ var preferredAudio = string.IsNullOrEmpty(user.Configuration.AudioLanguagePreference)
+ ? new string[] { }
+ : new[] { user.Configuration.AudioLanguagePreference };
+
+ source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.Configuration.PlayDefaultAudioTrack);
+ }
+
+ private IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
+ {
+ return sources.OrderBy(i =>
+ {
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
+
+ return 1;
+
+ }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
+
+ return stream == null || stream.Width == null ? 0 : stream.Width.Value;
+ })
+ .ToList();
+ }
+
+ private readonly Dictionary<string, LiveStreamInfo> _openStreams = new Dictionary<string, LiveStreamInfo>(StringComparer.OrdinalIgnoreCase);
+ private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1);
+
+ public async Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, bool enableAutoClose, CancellationToken cancellationToken)
+ {
+ await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var tuple = GetProvider(request.OpenToken);
+ var provider = tuple.Item1;
+
+ var mediaSourceTuple = await provider.OpenMediaSource(tuple.Item2, cancellationToken).ConfigureAwait(false);
+
+ var mediaSource = mediaSourceTuple.Item1;
+
+ if (string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
+ {
+ throw new InvalidOperationException(string.Format("{0} returned null LiveStreamId", provider.GetType().Name));
+ }
+
+ SetKeyProperties(provider, mediaSource);
+
+ var info = new LiveStreamInfo
+ {
+ Date = DateTime.UtcNow,
+ EnableCloseTimer = enableAutoClose,
+ Id = mediaSource.LiveStreamId,
+ MediaSource = mediaSource,
+ DirectStreamProvider = mediaSourceTuple.Item2
+ };
+
+ _openStreams[mediaSource.LiveStreamId] = info;
+
+ if (enableAutoClose)
+ {
+ StartCloseTimer();
+ }
+
+ var json = _jsonSerializer.SerializeToString(mediaSource);
+ _logger.Debug("Live stream opened: " + json);
+ var clone = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json);
+
+ if (!string.IsNullOrWhiteSpace(request.UserId))
+ {
+ var user = _userManager.GetUserById(request.UserId);
+ var item = string.IsNullOrWhiteSpace(request.ItemId)
+ ? null
+ : _libraryManager.GetItemById(request.ItemId);
+ SetUserProperties(item, clone, user);
+ }
+
+ return new LiveStreamResponse
+ {
+ MediaSource = clone
+ };
+ }
+ finally
+ {
+ _liveStreamSemaphore.Release();
+ }
+ }
+
+ public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ _logger.Debug("Getting already opened live stream {0}", id);
+
+ await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ LiveStreamInfo info;
+ if (_openStreams.TryGetValue(id, out info))
+ {
+ return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info.DirectStreamProvider);
+ }
+ else
+ {
+ throw new ResourceNotFoundException();
+ }
+ }
+ finally
+ {
+ _liveStreamSemaphore.Release();
+ }
+ }
+
+ public async Task<MediaSourceInfo> GetLiveStream(string id, CancellationToken cancellationToken)
+ {
+ var result = await GetLiveStreamWithDirectStreamProvider(id, cancellationToken).ConfigureAwait(false);
+ return result.Item1;
+ }
+
+ public async Task PingLiveStream(string id, CancellationToken cancellationToken)
+ {
+ await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ LiveStreamInfo info;
+ if (_openStreams.TryGetValue(id, out info))
+ {
+ info.Date = DateTime.UtcNow;
+ }
+ else
+ {
+ _logger.Error("Failed to ping live stream {0}", id);
+ }
+ }
+ finally
+ {
+ _liveStreamSemaphore.Release();
+ }
+ }
+
+ private async Task CloseLiveStreamWithProvider(IMediaSourceProvider provider, string streamId)
+ {
+ _logger.Info("Closing live stream {0} with provider {1}", streamId, provider.GetType().Name);
+
+ try
+ {
+ await provider.CloseMediaSource(streamId).ConfigureAwait(false);
+ }
+ catch (NotImplementedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing live stream {0}", ex, streamId);
+ }
+ }
+
+ public async Task CloseLiveStream(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ LiveStreamInfo current;
+
+ if (_openStreams.TryGetValue(id, out current))
+ {
+ _openStreams.Remove(id);
+ current.Closed = true;
+
+ if (current.MediaSource.RequiresClosing)
+ {
+ var tuple = GetProvider(id);
+
+ await CloseLiveStreamWithProvider(tuple.Item1, tuple.Item2).ConfigureAwait(false);
+ }
+
+ if (_openStreams.Count == 0)
+ {
+ StopCloseTimer();
+ }
+ }
+ }
+ finally
+ {
+ _liveStreamSemaphore.Release();
+ }
+ }
+
+ // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
+ private const char LiveStreamIdDelimeter = '_';
+
+ private Tuple<IMediaSourceProvider, string> GetProvider(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ throw new ArgumentException("key");
+ }
+
+ var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2);
+
+ var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N"), keys[0], StringComparison.OrdinalIgnoreCase));
+
+ var splitIndex = key.IndexOf(LiveStreamIdDelimeter);
+ var keyId = key.Substring(splitIndex + 1);
+
+ return new Tuple<IMediaSourceProvider, string>(provider, keyId);
+ }
+
+ private ITimer _closeTimer;
+ private readonly TimeSpan _openStreamMaxAge = TimeSpan.FromSeconds(180);
+
+ private void StartCloseTimer()
+ {
+ StopCloseTimer();
+
+ _closeTimer = _timerFactory.Create(CloseTimerCallback, null, _openStreamMaxAge, _openStreamMaxAge);
+ }
+
+ private void StopCloseTimer()
+ {
+ var timer = _closeTimer;
+
+ if (timer != null)
+ {
+ _closeTimer = null;
+ timer.Dispose();
+ }
+ }
+
+ private async void CloseTimerCallback(object state)
+ {
+ List<LiveStreamInfo> infos;
+ await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ infos = _openStreams
+ .Values
+ .Where(i => i.EnableCloseTimer && DateTime.UtcNow - i.Date > _openStreamMaxAge)
+ .ToList();
+ }
+ finally
+ {
+ _liveStreamSemaphore.Release();
+ }
+
+ foreach (var info in infos)
+ {
+ if (!info.Closed)
+ {
+ try
+ {
+ await CloseLiveStream(info.Id).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing media source", ex);
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ StopCloseTimer();
+ Dispose(true);
+ }
+
+ private readonly object _disposeLock = new object();
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ lock (_disposeLock)
+ {
+ foreach (var key in _openStreams.Keys.ToList())
+ {
+ var task = CloseLiveStream(key);
+
+ Task.WaitAll(task);
+ }
+ }
+ }
+ }
+
+ private class LiveStreamInfo
+ {
+ public DateTime Date;
+ public bool EnableCloseTimer;
+ public string Id;
+ public bool Closed;
+ public MediaSourceInfo MediaSource;
+ public IDirectStreamProvider DirectStreamProvider;
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs
new file mode 100644
index 000000000..7669dd0bf
--- /dev/null
+++ b/Emby.Server.Implementations/Library/MusicManager.cs
@@ -0,0 +1,157 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Library
+{
+ public class MusicManager : IMusicManager
+ {
+ private readonly ILibraryManager _libraryManager;
+
+ public MusicManager(ILibraryManager libraryManager)
+ {
+ _libraryManager = libraryManager;
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromSong(Audio item, User user)
+ {
+ var list = new List<Audio>
+ {
+ item
+ };
+
+ return list.Concat(GetInstantMixFromGenres(item.Genres, user));
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromArtist(MusicArtist artist, User user)
+ {
+ var genres = user.RootFolder
+ .GetRecursiveChildren(user, new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Audio).Name }
+ })
+ .Cast<Audio>()
+ .Where(i => i.HasAnyArtist(artist.Name))
+ .SelectMany(i => i.Genres)
+ .Concat(artist.Genres)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ return GetInstantMixFromGenres(genres, user);
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromAlbum(MusicAlbum item, User user)
+ {
+ var genres = item
+ .GetRecursiveChildren(user, new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Audio).Name }
+ })
+ .Cast<Audio>()
+ .SelectMany(i => i.Genres)
+ .Concat(item.Genres)
+ .DistinctNames();
+
+ return GetInstantMixFromGenres(genres, user);
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromFolder(Folder item, User user)
+ {
+ var genres = item
+ .GetRecursiveChildren(user, new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] {typeof(Audio).Name}
+ })
+ .Cast<Audio>()
+ .SelectMany(i => i.Genres)
+ .Concat(item.Genres)
+ .DistinctNames();
+
+ return GetInstantMixFromGenres(genres, user);
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromPlaylist(Playlist item, User user)
+ {
+ var genres = item
+ .GetRecursiveChildren(user, new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Audio).Name }
+ })
+ .Cast<Audio>()
+ .SelectMany(i => i.Genres)
+ .Concat(item.Genres)
+ .DistinctNames();
+
+ return GetInstantMixFromGenres(genres, user);
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromGenres(IEnumerable<string> genres, User user)
+ {
+ var genreList = genres.ToList();
+
+ var inputItems = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Audio).Name },
+
+ Genres = genreList.ToArray()
+
+ });
+
+ var genresDictionary = genreList.ToDictionary(i => i, StringComparer.OrdinalIgnoreCase);
+
+ return inputItems
+ .Cast<Audio>()
+ .Select(i => new Tuple<Audio, int>(i, i.Genres.Count(genresDictionary.ContainsKey)))
+ .Where(i => i.Item2 > 0)
+ .OrderByDescending(i => i.Item2)
+ .ThenBy(i => Guid.NewGuid())
+ .Select(i => i.Item1)
+ .Take(100)
+ .OrderBy(i => Guid.NewGuid());
+ }
+
+ public IEnumerable<Audio> GetInstantMixFromItem(BaseItem item, User user)
+ {
+ var genre = item as MusicGenre;
+ if (genre != null)
+ {
+ return GetInstantMixFromGenres(new[] { item.Name }, user);
+ }
+
+ var playlist = item as Playlist;
+ if (playlist != null)
+ {
+ return GetInstantMixFromPlaylist(playlist, user);
+ }
+
+ var album = item as MusicAlbum;
+ if (album != null)
+ {
+ return GetInstantMixFromAlbum(album, user);
+ }
+
+ var artist = item as MusicArtist;
+ if (artist != null)
+ {
+ return GetInstantMixFromArtist(artist, user);
+ }
+
+ var song = item as Audio;
+ if (song != null)
+ {
+ return GetInstantMixFromSong(song, user);
+ }
+
+ var folder = item as Folder;
+ if (folder != null)
+ {
+ return GetInstantMixFromFolder(folder, user);
+ }
+
+ return new Audio[] { };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
new file mode 100644
index 000000000..28ed2f53c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace Emby.Server.Implementations.Library
+{
+ public static class PathExtensions
+ {
+ /// <summary>
+ /// Gets the attribute value.
+ /// </summary>
+ /// <param name="str">The STR.</param>
+ /// <param name="attrib">The attrib.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">attrib</exception>
+ public static string GetAttributeValue(this string str, string attrib)
+ {
+ if (string.IsNullOrEmpty(str))
+ {
+ throw new ArgumentNullException("str");
+ }
+
+ if (string.IsNullOrEmpty(attrib))
+ {
+ throw new ArgumentNullException("attrib");
+ }
+
+ string srch = "[" + attrib + "=";
+ int start = str.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
+ if (start > -1)
+ {
+ start += srch.Length;
+ int end = str.IndexOf(']', start);
+ return str.Substring(start, end - start);
+ }
+ // for imdbid we also accept pattern matching
+ if (string.Equals(attrib, "imdbid", StringComparison.OrdinalIgnoreCase))
+ {
+ var m = Regex.Match(str, "tt\\d{7}", RegexOptions.IgnoreCase);
+ return m.Success ? m.Value : null;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs
new file mode 100644
index 000000000..1d3cacc1d
--- /dev/null
+++ b/Emby.Server.Implementations/Library/ResolverHelper.cs
@@ -0,0 +1,183 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// Class ResolverHelper
+ /// </summary>
+ public static class ResolverHelper
+ {
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="parent">The parent.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="directoryService">The directory service.</param>
+ /// <exception cref="System.ArgumentException">Item must have a path</exception>
+ public static void SetInitialItemValues(BaseItem item, Folder parent, IFileSystem fileSystem, ILibraryManager libraryManager, IDirectoryService directoryService)
+ {
+ // This version of the below method has no ItemResolveArgs, so we have to require the path already being set
+ if (string.IsNullOrWhiteSpace(item.Path))
+ {
+ throw new ArgumentException("Item must have a Path");
+ }
+
+ // If the resolver didn't specify this
+ if (parent != null)
+ {
+ item.SetParent(parent);
+ }
+
+ item.Id = libraryManager.GetNewItemId(item.Path, item.GetType());
+
+ item.IsLocked = item.Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1 ||
+ item.GetParents().Any(i => i.IsLocked);
+
+ // Make sure DateCreated and DateModified have values
+ var fileInfo = directoryService.GetFile(item.Path);
+ SetDateCreated(item, fileSystem, fileInfo);
+
+ EnsureName(item, fileInfo);
+ }
+
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ public static void SetInitialItemValues(BaseItem item, ItemResolveArgs args, IFileSystem fileSystem, ILibraryManager libraryManager)
+ {
+ // If the resolver didn't specify this
+ if (string.IsNullOrEmpty(item.Path))
+ {
+ item.Path = args.Path;
+ }
+
+ // If the resolver didn't specify this
+ if (args.Parent != null)
+ {
+ item.SetParent(args.Parent);
+ }
+
+ item.Id = libraryManager.GetNewItemId(item.Path, item.GetType());
+
+ // Make sure the item has a name
+ EnsureName(item, args.FileInfo);
+
+ item.IsLocked = item.Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1 ||
+ item.GetParents().Any(i => i.IsLocked);
+
+ // Make sure DateCreated and DateModified have values
+ EnsureDates(fileSystem, item, args);
+ }
+
+ /// <summary>
+ /// Ensures the name.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="fileInfo">The file information.</param>
+ private static void EnsureName(BaseItem item, FileSystemMetadata fileInfo)
+ {
+ // If the subclass didn't supply a name, add it here
+ if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(item.Path))
+ {
+ item.Name = GetDisplayName(fileInfo.Name, fileInfo.IsDirectory);
+ }
+ }
+
+ /// <summary>
+ /// Gets the display name.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="isDirectory">if set to <c>true</c> [is directory].</param>
+ /// <returns>System.String.</returns>
+ private static string GetDisplayName(string path, bool isDirectory)
+ {
+ return isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path);
+ }
+
+ /// <summary>
+ /// The MB name regex
+ /// </summary>
+ private static readonly Regex MbNameRegex = new Regex(@"(\[.*?\])");
+
+ internal static string StripBrackets(string inputString)
+ {
+ var output = MbNameRegex.Replace(inputString, string.Empty).Trim();
+ return Regex.Replace(output, @"\s+", " ");
+ }
+
+ /// <summary>
+ /// Ensures DateCreated and DateModified have values
+ /// </summary>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ private static void EnsureDates(IFileSystem fileSystem, BaseItem item, ItemResolveArgs args)
+ {
+ if (fileSystem == null)
+ {
+ throw new ArgumentNullException("fileSystem");
+ }
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+ if (args == null)
+ {
+ throw new ArgumentNullException("args");
+ }
+
+ // See if a different path came out of the resolver than what went in
+ if (!string.Equals(args.Path, item.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ var childData = args.IsDirectory ? args.GetFileSystemEntryByPath(item.Path) : null;
+
+ if (childData != null)
+ {
+ SetDateCreated(item, fileSystem, childData);
+ }
+ else
+ {
+ var fileData = fileSystem.GetFileSystemInfo(item.Path);
+
+ if (fileData.Exists)
+ {
+ SetDateCreated(item, fileSystem, fileData);
+ }
+ }
+ }
+ else
+ {
+ SetDateCreated(item, fileSystem, args.FileInfo);
+ }
+ }
+
+ private static void SetDateCreated(BaseItem item, IFileSystem fileSystem, FileSystemMetadata info)
+ {
+ var config = BaseItem.ConfigurationManager.GetMetadataConfiguration();
+
+ if (config.UseFileCreationTimeForDateAdded)
+ {
+ item.DateCreated = fileSystem.GetCreationTimeUtc(info);
+ }
+ else
+ {
+ item.DateCreated = DateTime.UtcNow;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
new file mode 100644
index 000000000..2e3d81474
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
@@ -0,0 +1,74 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using System;
+using MediaBrowser.Controller.Entities;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Audio
+{
+ /// <summary>
+ /// Class AudioResolver
+ /// </summary>
+ public class AudioResolver : ItemResolver<MediaBrowser.Controller.Entities.Audio.Audio>
+ {
+ private readonly ILibraryManager _libraryManager;
+
+ public AudioResolver(ILibraryManager libraryManager)
+ {
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get { return ResolverPriority.Last; }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Entities.Audio.Audio.</returns>
+ protected override MediaBrowser.Controller.Entities.Audio.Audio Resolve(ItemResolveArgs args)
+ {
+ // Return audio if the path is a file and has a matching extension
+
+ if (!args.IsDirectory)
+ {
+ var libraryOptions = args.GetLibraryOptions();
+
+ if (_libraryManager.IsAudioFile(args.Path, libraryOptions))
+ {
+ var collectionType = args.GetCollectionType();
+
+ var isMixed = string.IsNullOrWhiteSpace(collectionType);
+
+ // For conflicting extensions, give priority to videos
+ if (isMixed && _libraryManager.IsVideoFile(args.Path, libraryOptions))
+ {
+ return null;
+ }
+
+ var isStandalone = args.Parent == null;
+
+ if (isStandalone ||
+ string.Equals(collectionType, CollectionType.Music, StringComparison.OrdinalIgnoreCase) ||
+ isMixed)
+ {
+ return new MediaBrowser.Controller.Entities.Audio.Audio();
+ }
+
+ if (string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase))
+ {
+ return new AudioBook();
+ }
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
new file mode 100644
index 000000000..871b2d46d
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
@@ -0,0 +1,172 @@
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Naming.Audio;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Configuration;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Audio
+{
+ /// <summary>
+ /// Class MusicAlbumResolver
+ /// </summary>
+ public class MusicAlbumResolver : ItemResolver<MusicAlbum>
+ {
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryManager _libraryManager;
+
+ public MusicAlbumResolver(ILogger logger, IFileSystem fileSystem, ILibraryManager libraryManager)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get
+ {
+ // Behind special folder resolver
+ return ResolverPriority.Second;
+ }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>MusicAlbum.</returns>
+ protected override MusicAlbum Resolve(ItemResolveArgs args)
+ {
+ if (!args.IsDirectory) return null;
+
+ // Avoid mis-identifying top folders
+ if (args.HasParent<MusicAlbum>()) return null;
+ if (args.Parent.IsRoot) return null;
+
+ var collectionType = args.GetCollectionType();
+
+ var isMusicMediaFolder = string.Equals(collectionType, CollectionType.Music, StringComparison.OrdinalIgnoreCase);
+
+ // If there's a collection type and it's not music, don't allow it.
+ if (!isMusicMediaFolder)
+ {
+ return null;
+ }
+
+ return IsMusicAlbum(args) ? new MusicAlbum() : null;
+ }
+
+
+ /// <summary>
+ /// Determine if the supplied file data points to a music album
+ /// </summary>
+ public bool IsMusicAlbum(string path, IDirectoryService directoryService, LibraryOptions libraryOptions)
+ {
+ return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, libraryOptions, _libraryManager);
+ }
+
+ /// <summary>
+ /// Determine if the supplied resolve args should be considered a music album
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns><c>true</c> if [is music album] [the specified args]; otherwise, <c>false</c>.</returns>
+ private bool IsMusicAlbum(ItemResolveArgs args)
+ {
+ // Args points to an album if parent is an Artist folder or it directly contains music
+ if (args.IsDirectory)
+ {
+ //if (args.Parent is MusicArtist) return true; //saves us from testing children twice
+ if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService, _logger, _fileSystem, args.GetLibraryOptions(), _libraryManager)) return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Determine if the supplied list contains what we should consider music
+ /// </summary>
+ private bool ContainsMusic(IEnumerable<FileSystemMetadata> list,
+ bool allowSubfolders,
+ IDirectoryService directoryService,
+ ILogger logger,
+ IFileSystem fileSystem,
+ LibraryOptions libraryOptions,
+ ILibraryManager libraryManager)
+ {
+ var discSubfolderCount = 0;
+ var notMultiDisc = false;
+
+ foreach (var fileSystemInfo in list)
+ {
+ if (fileSystemInfo.IsDirectory)
+ {
+ if (allowSubfolders)
+ {
+ var path = fileSystemInfo.FullName;
+ var isMultiDisc = IsMultiDiscFolder(path, libraryOptions);
+
+ if (isMultiDisc)
+ {
+ var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService, logger, fileSystem, libraryOptions, libraryManager);
+
+ if (hasMusic)
+ {
+ logger.Debug("Found multi-disc folder: " + path);
+ discSubfolderCount++;
+ }
+ }
+ else
+ {
+ var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService, logger, fileSystem, libraryOptions, libraryManager);
+
+ if (hasMusic)
+ {
+ // If there are folders underneath with music that are not multidisc, then this can't be a multi-disc album
+ notMultiDisc = true;
+ }
+ }
+ }
+ }
+
+ var fullName = fileSystemInfo.FullName;
+
+ if (libraryManager.IsAudioFile(fullName, libraryOptions))
+ {
+ return true;
+ }
+ }
+
+ if (notMultiDisc)
+ {
+ return false;
+ }
+
+ return discSubfolderCount > 0;
+ }
+
+ private bool IsMultiDiscFolder(string path, LibraryOptions libraryOptions)
+ {
+ var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(libraryOptions);
+
+ var parser = new AlbumParser(namingOptions, new NullLogger());
+ var result = parser.ParseMultiPart(path);
+
+ return result.IsMultiPart;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
new file mode 100644
index 000000000..2971405b9
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
@@ -0,0 +1,94 @@
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Audio
+{
+ /// <summary>
+ /// Class MusicArtistResolver
+ /// </summary>
+ public class MusicArtistResolver : ItemResolver<MusicArtist>
+ {
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+
+ public MusicArtistResolver(ILogger logger, IFileSystem fileSystem, ILibraryManager libraryManager, IServerConfigurationManager config)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _libraryManager = libraryManager;
+ _config = config;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get
+ {
+ // Behind special folder resolver
+ return ResolverPriority.Second;
+ }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>MusicArtist.</returns>
+ protected override MusicArtist Resolve(ItemResolveArgs args)
+ {
+ if (!args.IsDirectory) return null;
+
+ // Don't allow nested artists
+ if (args.HasParent<MusicArtist>() || args.HasParent<MusicAlbum>())
+ {
+ return null;
+ }
+
+ var collectionType = args.GetCollectionType();
+
+ var isMusicMediaFolder = string.Equals(collectionType, CollectionType.Music, StringComparison.OrdinalIgnoreCase);
+
+ // If there's a collection type and it's not music, it can't be a series
+ if (!isMusicMediaFolder)
+ {
+ return null;
+ }
+
+ if (args.ContainsFileSystemEntryByName("artist.nfo"))
+ {
+ return new MusicArtist();
+ }
+
+ if (_config.Configuration.EnableSimpleArtistDetection)
+ {
+ return null;
+ }
+
+ // Avoid mis-identifying top folders
+ if (args.Parent.IsRoot) return null;
+
+ var directoryService = args.DirectoryService;
+
+ var albumResolver = new MusicAlbumResolver(_logger, _fileSystem, _libraryManager);
+
+ // If we contain an album assume we are an artist folder
+ return args.FileSystemChildren.Where(i => i.IsDirectory).Any(i => albumResolver.IsMusicAlbum(i.FullName, directoryService, args.GetLibraryOptions())) ? new MusicArtist() : null;
+ }
+
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
new file mode 100644
index 000000000..384ed8dac
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
@@ -0,0 +1,314 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Naming.Video;
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ /// <summary>
+ /// Resolves a Path into a Video or Video subclass
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ public abstract class BaseVideoResolver<T> : MediaBrowser.Controller.Resolvers.ItemResolver<T>
+ where T : Video, new()
+ {
+ protected readonly ILibraryManager LibraryManager;
+
+ protected BaseVideoResolver(ILibraryManager libraryManager)
+ {
+ LibraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>`0.</returns>
+ protected override T Resolve(ItemResolveArgs args)
+ {
+ return ResolveVideo<T>(args, false);
+ }
+
+ /// <summary>
+ /// Resolves the video.
+ /// </summary>
+ /// <typeparam name="TVideoType">The type of the T video type.</typeparam>
+ /// <param name="args">The args.</param>
+ /// <param name="parseName">if set to <c>true</c> [parse name].</param>
+ /// <returns>``0.</returns>
+ protected TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName)
+ where TVideoType : Video, new()
+ {
+ var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions();
+
+ // If the path is a file check for a matching extensions
+ var parser = new MediaBrowser.Naming.Video.VideoResolver(namingOptions, new NullLogger());
+
+ if (args.IsDirectory)
+ {
+ TVideoType video = null;
+ VideoFileInfo videoInfo = null;
+
+ // Loop through each child file/folder and see if we find a video
+ foreach (var child in args.FileSystemChildren)
+ {
+ var filename = child.Name;
+
+ if (child.IsDirectory)
+ {
+ if (IsDvdDirectory(child.FullName, filename, args.DirectoryService))
+ {
+ videoInfo = parser.ResolveDirectory(args.Path);
+
+ if (videoInfo == null)
+ {
+ return null;
+ }
+
+ video = new TVideoType
+ {
+ Path = args.Path,
+ VideoType = VideoType.Dvd,
+ ProductionYear = videoInfo.Year
+ };
+ break;
+ }
+ if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService))
+ {
+ videoInfo = parser.ResolveDirectory(args.Path);
+
+ if (videoInfo == null)
+ {
+ return null;
+ }
+
+ video = new TVideoType
+ {
+ Path = args.Path,
+ VideoType = VideoType.BluRay,
+ ProductionYear = videoInfo.Year
+ };
+ break;
+ }
+ }
+ else if (IsDvdFile(filename))
+ {
+ videoInfo = parser.ResolveDirectory(args.Path);
+
+ if (videoInfo == null)
+ {
+ return null;
+ }
+
+ video = new TVideoType
+ {
+ Path = args.Path,
+ VideoType = VideoType.Dvd,
+ ProductionYear = videoInfo.Year
+ };
+ break;
+ }
+ }
+
+ if (video != null)
+ {
+ video.Name = parseName ?
+ videoInfo.Name :
+ Path.GetFileName(args.Path);
+
+ Set3DFormat(video, videoInfo);
+ }
+
+ return video;
+ }
+ else
+ {
+ var videoInfo = parser.Resolve(args.Path, false, false);
+
+ if (videoInfo == null)
+ {
+ return null;
+ }
+
+ if (LibraryManager.IsVideoFile(args.Path, args.GetLibraryOptions()) || videoInfo.IsStub)
+ {
+ var path = args.Path;
+
+ var video = new TVideoType
+ {
+ Path = path,
+ IsInMixedFolder = true,
+ ProductionYear = videoInfo.Year
+ };
+
+ SetVideoType(video, videoInfo);
+
+ video.Name = parseName ?
+ videoInfo.Name :
+ Path.GetFileNameWithoutExtension(args.Path);
+
+ Set3DFormat(video, videoInfo);
+
+ return video;
+ }
+ }
+
+ return null;
+ }
+
+ protected void SetVideoType(Video video, VideoFileInfo videoInfo)
+ {
+ var extension = Path.GetExtension(video.Path);
+ video.VideoType = string.Equals(extension, ".iso", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(extension, ".img", StringComparison.OrdinalIgnoreCase) ?
+ VideoType.Iso :
+ VideoType.VideoFile;
+
+ video.IsShortcut = string.Equals(extension, ".strm", StringComparison.OrdinalIgnoreCase);
+ video.IsPlaceHolder = videoInfo.IsStub;
+
+ if (videoInfo.IsStub)
+ {
+ if (string.Equals(videoInfo.StubType, "dvd", StringComparison.OrdinalIgnoreCase))
+ {
+ video.VideoType = VideoType.Dvd;
+ }
+ else if (string.Equals(videoInfo.StubType, "hddvd", StringComparison.OrdinalIgnoreCase))
+ {
+ video.VideoType = VideoType.HdDvd;
+ video.IsHD = true;
+ }
+ else if (string.Equals(videoInfo.StubType, "bluray", StringComparison.OrdinalIgnoreCase))
+ {
+ video.VideoType = VideoType.BluRay;
+ video.IsHD = true;
+ }
+ else if (string.Equals(videoInfo.StubType, "hdtv", StringComparison.OrdinalIgnoreCase))
+ {
+ video.IsHD = true;
+ }
+ }
+
+ SetIsoType(video);
+ }
+
+ protected void SetIsoType(Video video)
+ {
+ if (video.VideoType == VideoType.Iso)
+ {
+ if (video.Path.IndexOf("dvd", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ video.IsoType = IsoType.Dvd;
+ }
+ else if (video.Path.IndexOf("bluray", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ video.IsoType = IsoType.BluRay;
+ }
+ }
+ }
+
+ protected void Set3DFormat(Video video, bool is3D, string format3D)
+ {
+ if (is3D)
+ {
+ if (string.Equals(format3D, "fsbs", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.FullSideBySide;
+ }
+ else if (string.Equals(format3D, "ftab", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.FullTopAndBottom;
+ }
+ else if (string.Equals(format3D, "hsbs", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.HalfSideBySide;
+ }
+ else if (string.Equals(format3D, "htab", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.HalfTopAndBottom;
+ }
+ else if (string.Equals(format3D, "sbs", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.HalfSideBySide;
+ }
+ else if (string.Equals(format3D, "sbs3d", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.HalfSideBySide;
+ }
+ else if (string.Equals(format3D, "tab", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.HalfTopAndBottom;
+ }
+ else if (string.Equals(format3D, "mvc", StringComparison.OrdinalIgnoreCase))
+ {
+ video.Video3DFormat = Video3DFormat.MVC;
+ }
+ }
+ }
+
+ protected void Set3DFormat(Video video, VideoFileInfo videoInfo)
+ {
+ Set3DFormat(video, videoInfo.Is3D, videoInfo.Format3D);
+ }
+
+ protected void Set3DFormat(Video video)
+ {
+ var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions();
+
+ var resolver = new Format3DParser(namingOptions, new NullLogger());
+ var result = resolver.Parse(video.Path);
+
+ Set3DFormat(video, result.Is3D, result.Format3D);
+ }
+
+ /// <summary>
+ /// Determines whether [is DVD directory] [the specified directory name].
+ /// </summary>
+ protected bool IsDvdDirectory(string fullPath, string directoryName, IDirectoryService directoryService)
+ {
+ if (!string.Equals(directoryName, "video_ts", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return directoryService.GetFiles(fullPath).Any(i => string.Equals(i.Extension, ".vob", StringComparison.OrdinalIgnoreCase));
+ }
+
+ /// <summary>
+ /// Determines whether [is DVD file] [the specified name].
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns><c>true</c> if [is DVD file] [the specified name]; otherwise, <c>false</c>.</returns>
+ protected bool IsDvdFile(string name)
+ {
+ return string.Equals(name, "video_ts.ifo", StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// <summary>
+ /// Determines whether [is blu ray directory] [the specified directory name].
+ /// </summary>
+ protected bool IsBluRayDirectory(string fullPath, string directoryName, IDirectoryService directoryService)
+ {
+ if (!string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return true;
+ //var blurayExtensions = new[]
+ //{
+ // ".mts",
+ // ".m2ts",
+ // ".bdmv",
+ // ".mpls"
+ //};
+
+ //return directoryService.GetFiles(fullPath).Any(i => blurayExtensions.Contains(i.Extension ?? string.Empty, StringComparer.OrdinalIgnoreCase));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
new file mode 100644
index 000000000..4852c3c6a
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Books
+{
+ /// <summary>
+ ///
+ /// </summary>
+ public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book>
+ {
+ private readonly string[] _validExtensions = {".pdf", ".epub", ".mobi", ".cbr", ".cbz"};
+
+ /// <summary>
+ ///
+ /// </summary>
+ /// <param name="args"></param>
+ /// <returns></returns>
+ protected override Book Resolve(ItemResolveArgs args)
+ {
+ var collectionType = args.GetCollectionType();
+
+ // Only process items that are in a collection folder containing books
+ if (!string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ if (args.IsDirectory)
+ {
+ return GetBook(args);
+ }
+
+ var extension = Path.GetExtension(args.Path);
+
+ if (extension != null && _validExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+ {
+ // It's a book
+ return new Book
+ {
+ Path = args.Path,
+ IsInMixedFolder = true
+ };
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ ///
+ /// </summary>
+ /// <param name="args"></param>
+ /// <returns></returns>
+ private Book GetBook(ItemResolveArgs args)
+ {
+ var bookFiles = args.FileSystemChildren.Where(f =>
+ {
+ var fileExtension = Path.GetExtension(f.FullName) ??
+ string.Empty;
+
+ return _validExtensions.Contains(fileExtension,
+ StringComparer
+ .OrdinalIgnoreCase);
+ }).ToList();
+
+ // Don't return a Book if there is more (or less) than one document in the directory
+ if (bookFiles.Count != 1)
+ return null;
+
+ return new Book
+ {
+ Path = bookFiles[0].FullName
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs
new file mode 100644
index 000000000..5e73baa5c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs
@@ -0,0 +1,56 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ /// <summary>
+ /// Class FolderResolver
+ /// </summary>
+ public class FolderResolver : FolderResolver<Folder>
+ {
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get { return ResolverPriority.Last; }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Folder.</returns>
+ protected override Folder Resolve(ItemResolveArgs args)
+ {
+ if (args.IsDirectory)
+ {
+ return new Folder();
+ }
+
+ return null;
+ }
+ }
+
+ /// <summary>
+ /// Class FolderResolver
+ /// </summary>
+ /// <typeparam name="TItemType">The type of the T item type.</typeparam>
+ public abstract class FolderResolver<TItemType> : ItemResolver<TItemType>
+ where TItemType : Folder, new()
+ {
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args)
+ {
+ base.SetInitialItemValues(item, args);
+
+ item.IsRoot = args.Parent == null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs
new file mode 100644
index 000000000..b4a37be5f
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs
@@ -0,0 +1,62 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ /// <summary>
+ /// Class ItemResolver
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ public abstract class ItemResolver<T> : IItemResolver
+ where T : BaseItem, new()
+ {
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>`0.</returns>
+ protected virtual T Resolve(ItemResolveArgs args)
+ {
+ return null;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public virtual ResolverPriority Priority
+ {
+ get
+ {
+ return ResolverPriority.First;
+ }
+ }
+
+ /// <summary>
+ /// Sets initial values on the newly resolved item
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ protected virtual void SetInitialItemValues(T item, ItemResolveArgs args)
+ {
+ }
+
+ /// <summary>
+ /// Resolves the path.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>BaseItem.</returns>
+ BaseItem IItemResolver.ResolvePath(ItemResolveArgs args)
+ {
+ var item = Resolve(args);
+
+ if (item != null)
+ {
+ SetInitialItemValues(item, args);
+ }
+
+ return item;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
new file mode 100644
index 000000000..df441c5ed
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
@@ -0,0 +1,77 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using System;
+using System.IO;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Movies
+{
+ /// <summary>
+ /// Class BoxSetResolver
+ /// </summary>
+ public class BoxSetResolver : FolderResolver<BoxSet>
+ {
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>BoxSet.</returns>
+ protected override BoxSet Resolve(ItemResolveArgs args)
+ {
+ // It's a boxset if all of the following conditions are met:
+ // Is a Directory
+ // Contains [boxset] in the path
+ if (args.IsDirectory)
+ {
+ var filename = Path.GetFileName(args.Path);
+
+ if (string.IsNullOrEmpty(filename))
+ {
+ return null;
+ }
+
+ if (filename.IndexOf("[boxset]", StringComparison.OrdinalIgnoreCase) != -1 ||
+ args.ContainsFileSystemEntryByName("collection.xml"))
+ {
+ return new BoxSet
+ {
+ Path = args.Path,
+ Name = ResolverHelper.StripBrackets(Path.GetFileName(args.Path))
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ protected override void SetInitialItemValues(BoxSet item, ItemResolveArgs args)
+ {
+ base.SetInitialItemValues(item, args);
+
+ SetProviderIdFromPath(item);
+ }
+
+ /// <summary>
+ /// Sets the provider id from path.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ private void SetProviderIdFromPath(BaseItem item)
+ {
+ //we need to only look at the name of this actual item (not parents)
+ var justName = Path.GetFileName(item.Path);
+
+ var id = justName.GetAttributeValue("tmdbid");
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ item.SetProviderId(MetadataProviders.Tmdb, id);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
new file mode 100644
index 000000000..55a63b4e5
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -0,0 +1,540 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Naming.Video;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.Library.Resolvers.Movies
+{
+ /// <summary>
+ /// Class MovieResolver
+ /// </summary>
+ public class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
+ {
+ public MovieResolver(ILibraryManager libraryManager)
+ : base(libraryManager)
+ {
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get
+ {
+ // Give plugins a chance to catch iso's first
+ // Also since we have to loop through child files looking for videos,
+ // see if we can avoid some of that by letting other resolvers claim folders first
+ // Also run after series resolver
+ return ResolverPriority.Third;
+ }
+ }
+
+ public MultiItemResolverResult ResolveMultiple(Folder parent,
+ List<FileSystemMetadata> files,
+ string collectionType,
+ IDirectoryService directoryService)
+ {
+ var result = ResolveMultipleInternal(parent, files, collectionType, directoryService);
+
+ if (result != null)
+ {
+ foreach (var item in result.Items)
+ {
+ SetInitialItemValues((Video)item, null);
+ }
+ }
+
+ return result;
+ }
+
+ private MultiItemResolverResult ResolveMultipleInternal(Folder parent,
+ List<FileSystemMetadata> files,
+ string collectionType,
+ IDirectoryService directoryService)
+ {
+ if (IsInvalid(parent, collectionType))
+ {
+ return null;
+ }
+
+ if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
+ {
+ return ResolveVideos<MusicVideo>(parent, files, directoryService, false);
+ }
+
+ if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase))
+ {
+ return ResolveVideos<Video>(parent, files, directoryService, false);
+ }
+
+ if (string.IsNullOrEmpty(collectionType))
+ {
+ // Owned items should just use the plain video type
+ if (parent == null)
+ {
+ return ResolveVideos<Video>(parent, files, directoryService, false);
+ }
+
+ if (parent is Series || parent.GetParents().OfType<Series>().Any())
+ {
+ return null;
+ }
+
+ return ResolveVideos<Movie>(parent, files, directoryService, false);
+ }
+
+ if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
+ {
+ return ResolveVideos<Movie>(parent, files, directoryService, true);
+ }
+
+ return null;
+ }
+
+ private MultiItemResolverResult ResolveVideos<T>(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, IDirectoryService directoryService, bool suppportMultiEditions)
+ where T : Video, new()
+ {
+ var files = new List<FileSystemMetadata>();
+ var videos = new List<BaseItem>();
+ var leftOver = new List<FileSystemMetadata>();
+
+ // Loop through each child file/folder and see if we find a video
+ foreach (var child in fileSystemEntries)
+ {
+ if (child.IsDirectory)
+ {
+ leftOver.Add(child);
+ }
+ else if (IsIgnored(child.Name))
+ {
+
+ }
+ else
+ {
+ files.Add(child);
+ }
+ }
+
+ var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions();
+
+ var resolver = new VideoListResolver(namingOptions, new NullLogger());
+ var resolverResult = resolver.Resolve(files, suppportMultiEditions).ToList();
+
+ var result = new MultiItemResolverResult
+ {
+ ExtraFiles = leftOver,
+ Items = videos
+ };
+
+ var isInMixedFolder = resolverResult.Count > 1;
+
+ foreach (var video in resolverResult)
+ {
+ var firstVideo = video.Files.First();
+
+ var videoItem = new T
+ {
+ Path = video.Files[0].Path,
+ IsInMixedFolder = isInMixedFolder,
+ ProductionYear = video.Year,
+ Name = video.Name,
+ AdditionalParts = video.Files.Skip(1).Select(i => i.Path).ToList(),
+ LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToList()
+ };
+
+ SetVideoType(videoItem, firstVideo);
+ Set3DFormat(videoItem, firstVideo);
+
+ result.Items.Add(videoItem);
+ }
+
+ result.ExtraFiles.AddRange(files.Where(i => !ContainsFile(resolverResult, i)));
+
+ return result;
+ }
+
+ private bool ContainsFile(List<VideoInfo> result, FileSystemMetadata file)
+ {
+ return result.Any(i => ContainsFile(i, file));
+ }
+
+ private bool ContainsFile(VideoInfo result, FileSystemMetadata file)
+ {
+ return result.Files.Any(i => ContainsFile(i, file)) ||
+ result.AlternateVersions.Any(i => ContainsFile(i, file)) ||
+ result.Extras.Any(i => ContainsFile(i, file));
+ }
+
+ private bool ContainsFile(VideoFileInfo result, FileSystemMetadata file)
+ {
+ return string.Equals(result.Path, file.FullName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Video.</returns>
+ protected override Video Resolve(ItemResolveArgs args)
+ {
+ var collectionType = args.GetCollectionType();
+
+ if (IsInvalid(args.Parent, collectionType))
+ {
+ return null;
+ }
+
+ // Find movies with their own folders
+ if (args.IsDirectory)
+ {
+ var files = args.FileSystemChildren
+ .Where(i => !LibraryManager.IgnoreFile(i, args.Parent))
+ .ToList();
+
+ if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
+ {
+ return FindMovie<MusicVideo>(args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+ }
+
+ if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase))
+ {
+ return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+ }
+
+ if (string.IsNullOrEmpty(collectionType))
+ {
+ // Owned items will be caught by the plain video resolver
+ if (args.Parent == null)
+ {
+ //return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType);
+ return null;
+ }
+
+ if (args.HasParent<Series>())
+ {
+ return null;
+ }
+
+ {
+ return FindMovie<Movie>(args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
+ }
+ }
+
+ if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
+ {
+ return FindMovie<Movie>(args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
+ }
+
+ return null;
+ }
+
+ // Owned items will be caught by the plain video resolver
+ if (args.Parent == null)
+ {
+ return null;
+ }
+
+ Video item = null;
+
+ if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
+ {
+ item = ResolveVideo<MusicVideo>(args, false);
+ }
+
+ // To find a movie file, the collection type must be movies or boxsets
+ else if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
+ {
+ item = ResolveVideo<Movie>(args, true);
+ }
+
+ else if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase))
+ {
+ item = ResolveVideo<Video>(args, false);
+ }
+ else if (string.IsNullOrEmpty(collectionType))
+ {
+ if (args.HasParent<Series>())
+ {
+ return null;
+ }
+
+ item = ResolveVideo<Video>(args, false);
+ }
+
+ if (item != null)
+ {
+ item.IsInMixedFolder = true;
+ }
+
+ return item;
+ }
+
+ private bool IsIgnored(string filename)
+ {
+ // Ignore samples
+ var sampleFilename = " " + filename.Replace(".", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("-", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("_", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("!", " ", StringComparison.OrdinalIgnoreCase);
+
+ if (sampleFilename.IndexOf(" sample ", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ protected override void SetInitialItemValues(Video item, ItemResolveArgs args)
+ {
+ base.SetInitialItemValues(item, args);
+
+ SetProviderIdsFromPath(item);
+ }
+
+ /// <summary>
+ /// Sets the provider id from path.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ private void SetProviderIdsFromPath(Video item)
+ {
+ if (item is Movie || item is MusicVideo)
+ {
+ //we need to only look at the name of this actual item (not parents)
+ var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path) : Path.GetFileName(item.ContainingFolderPath);
+
+ if (!string.IsNullOrWhiteSpace(justName))
+ {
+ // check for tmdb id
+ var tmdbid = justName.GetAttributeValue("tmdbid");
+
+ if (!string.IsNullOrWhiteSpace(tmdbid))
+ {
+ item.SetProviderId(MetadataProviders.Tmdb, tmdbid);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.Path))
+ {
+ // check for imdb id - we use full media path, as we can assume, that this will match in any use case (wither id in parent dir or in file name)
+ var imdbid = item.Path.GetAttributeValue("imdbid");
+
+ if (!string.IsNullOrWhiteSpace(imdbid))
+ {
+ item.SetProviderId(MetadataProviders.Imdb, imdbid);
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Finds a movie based on a child file system entries
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <returns>Movie.</returns>
+ private T FindMovie<T>(string path, Folder parent, List<FileSystemMetadata> fileSystemEntries, IDirectoryService directoryService, string collectionType, bool allowFilesAsFolders)
+ where T : Video, new()
+ {
+ var multiDiscFolders = new List<FileSystemMetadata>();
+
+ // Search for a folder rip
+ foreach (var child in fileSystemEntries)
+ {
+ var filename = child.Name;
+
+ if (child.IsDirectory)
+ {
+ if (IsDvdDirectory(child.FullName, filename, directoryService))
+ {
+ var movie = new T
+ {
+ Path = path,
+ VideoType = VideoType.Dvd
+ };
+ Set3DFormat(movie);
+ return movie;
+ }
+ if (IsBluRayDirectory(child.FullName, filename, directoryService))
+ {
+ var movie = new T
+ {
+ Path = path,
+ VideoType = VideoType.BluRay
+ };
+ Set3DFormat(movie);
+ return movie;
+ }
+
+ multiDiscFolders.Add(child);
+ }
+ else if (IsDvdFile(filename))
+ {
+ var movie = new T
+ {
+ Path = path,
+ VideoType = VideoType.Dvd
+ };
+ Set3DFormat(movie);
+ return movie;
+ }
+ }
+
+ if (allowFilesAsFolders)
+ {
+ // TODO: Allow GetMultiDiscMovie in here
+ var supportsMultiVersion = !string.Equals(collectionType, CollectionType.HomeVideos) &&
+ !string.Equals(collectionType, CollectionType.Photos) &&
+ !string.Equals(collectionType, CollectionType.MusicVideos);
+
+ var result = ResolveVideos<T>(parent, fileSystemEntries, directoryService, supportsMultiVersion);
+
+ if (result.Items.Count == 1)
+ {
+ var movie = (T)result.Items[0];
+ movie.IsInMixedFolder = false;
+ movie.Name = Path.GetFileName(movie.ContainingFolderPath);
+ return movie;
+ }
+
+ if (result.Items.Count == 0 && multiDiscFolders.Count > 0)
+ {
+ return GetMultiDiscMovie<T>(multiDiscFolders, directoryService);
+ }
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Gets the multi disc movie.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="multiDiscFolders">The folders.</param>
+ /// <param name="directoryService">The directory service.</param>
+ /// <returns>``0.</returns>
+ private T GetMultiDiscMovie<T>(List<FileSystemMetadata> multiDiscFolders, IDirectoryService directoryService)
+ where T : Video, new()
+ {
+ var videoTypes = new List<VideoType>();
+
+ var folderPaths = multiDiscFolders.Select(i => i.FullName).Where(i =>
+ {
+ var subFileEntries = directoryService.GetFileSystemEntries(i)
+ .ToList();
+
+ var subfolders = subFileEntries
+ .Where(e => e.IsDirectory)
+ .ToList();
+
+ if (subfolders.Any(s => IsDvdDirectory(s.FullName, s.Name, directoryService)))
+ {
+ videoTypes.Add(VideoType.Dvd);
+ return true;
+ }
+ if (subfolders.Any(s => IsBluRayDirectory(s.FullName, s.Name, directoryService)))
+ {
+ videoTypes.Add(VideoType.BluRay);
+ return true;
+ }
+
+ var subFiles = subFileEntries
+ .Where(e => !e.IsDirectory)
+ .Select(d => d.Name);
+
+ if (subFiles.Any(IsDvdFile))
+ {
+ videoTypes.Add(VideoType.Dvd);
+ return true;
+ }
+
+ return false;
+
+ }).OrderBy(i => i).ToList();
+
+ // If different video types were found, don't allow this
+ if (videoTypes.Distinct().Count() > 1)
+ {
+ return null;
+ }
+
+ if (folderPaths.Count == 0)
+ {
+ return null;
+ }
+
+ var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions();
+ var resolver = new StackResolver(namingOptions, new NullLogger());
+
+ var result = resolver.ResolveDirectories(folderPaths);
+
+ if (result.Stacks.Count != 1)
+ {
+ return null;
+ }
+
+ var returnVideo = new T
+ {
+ Path = folderPaths[0],
+
+ AdditionalParts = folderPaths.Skip(1).ToList(),
+
+ VideoType = videoTypes[0],
+
+ Name = result.Stacks[0].Name
+ };
+
+ SetIsoType(returnVideo);
+
+ return returnVideo;
+ }
+
+ private bool IsInvalid(Folder parent, string collectionType)
+ {
+ if (parent != null)
+ {
+ if (parent.IsRoot)
+ {
+ return true;
+ }
+ }
+
+ var validCollectionTypes = new[]
+ {
+ CollectionType.Movies,
+ CollectionType.HomeVideos,
+ CollectionType.MusicVideos,
+ CollectionType.Movies,
+ CollectionType.Photos
+ };
+
+ if (string.IsNullOrWhiteSpace(collectionType))
+ {
+ return false;
+ }
+
+ return !validCollectionTypes.Contains(collectionType, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs
new file mode 100644
index 000000000..3d7ede879
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs
@@ -0,0 +1,56 @@
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.IO;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ public class PhotoAlbumResolver : FolderResolver<PhotoAlbum>
+ {
+ private readonly IImageProcessor _imageProcessor;
+ public PhotoAlbumResolver(IImageProcessor imageProcessor)
+ {
+ _imageProcessor = imageProcessor;
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Trailer.</returns>
+ protected override PhotoAlbum Resolve(ItemResolveArgs args)
+ {
+ // Must be an image file within a photo collection
+ if (args.IsDirectory && string.Equals(args.GetCollectionType(), CollectionType.Photos, StringComparison.OrdinalIgnoreCase))
+ {
+ if (HasPhotos(args))
+ {
+ return new PhotoAlbum
+ {
+ Path = args.Path
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private bool HasPhotos(ItemResolveArgs args)
+ {
+ return args.FileSystemChildren.Any(i => (!i.IsDirectory) && PhotoResolver.IsImageFile(i.FullName, _imageProcessor));
+ }
+
+ public override ResolverPriority Priority
+ {
+ get
+ {
+ // Behind special folder resolver
+ return ResolverPriority.Second;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs
new file mode 100644
index 000000000..df39e57ad
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs
@@ -0,0 +1,103 @@
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Configuration;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ public class PhotoResolver : ItemResolver<Photo>
+ {
+ private readonly IImageProcessor _imageProcessor;
+ private readonly ILibraryManager _libraryManager;
+
+ public PhotoResolver(IImageProcessor imageProcessor, ILibraryManager libraryManager)
+ {
+ _imageProcessor = imageProcessor;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Trailer.</returns>
+ protected override Photo Resolve(ItemResolveArgs args)
+ {
+ if (!args.IsDirectory)
+ {
+ // Must be an image file within a photo collection
+ var collectionType = args.GetCollectionType();
+
+
+ if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) ||
+ (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.GetLibraryOptions().EnablePhotos))
+ {
+ if (IsImageFile(args.Path, _imageProcessor))
+ {
+ var filename = Path.GetFileNameWithoutExtension(args.Path);
+
+ // Make sure the image doesn't belong to a video file
+ if (args.DirectoryService.GetFiles(Path.GetDirectoryName(args.Path)).Any(i => IsOwnedByMedia(args.GetLibraryOptions(), i, filename)))
+ {
+ return null;
+ }
+
+ return new Photo
+ {
+ Path = args.Path
+ };
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private bool IsOwnedByMedia(LibraryOptions libraryOptions, FileSystemMetadata file, string imageFilename)
+ {
+ if (_libraryManager.IsVideoFile(file.FullName, libraryOptions) && imageFilename.StartsWith(Path.GetFileNameWithoutExtension(file.Name), StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static readonly string[] IgnoreFiles =
+ {
+ "folder",
+ "thumb",
+ "landscape",
+ "fanart",
+ "backdrop",
+ "poster",
+ "cover"
+ };
+
+ internal static bool IsImageFile(string path, IImageProcessor imageProcessor)
+ {
+ var filename = Path.GetFileNameWithoutExtension(path) ?? string.Empty;
+
+ if (IgnoreFiles.Contains(filename, StringComparer.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (IgnoreFiles.Any(i => filename.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1))
+ {
+ return false;
+ }
+
+ return imageProcessor.SupportedInputFormats.Contains((Path.GetExtension(path) ?? string.Empty).TrimStart('.'), StringComparer.OrdinalIgnoreCase);
+ }
+
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
new file mode 100644
index 000000000..8c59cf20f
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -0,0 +1,42 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using System;
+using System.IO;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ public class PlaylistResolver : FolderResolver<Playlist>
+ {
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>BoxSet.</returns>
+ protected override Playlist Resolve(ItemResolveArgs args)
+ {
+ // It's a boxset if all of the following conditions are met:
+ // Is a Directory
+ // Contains [playlist] in the path
+ if (args.IsDirectory)
+ {
+ var filename = Path.GetFileName(args.Path);
+
+ if (string.IsNullOrEmpty(filename))
+ {
+ return null;
+ }
+
+ if (filename.IndexOf("[playlist]", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return new Playlist
+ {
+ Path = args.Path,
+ Name = ResolverHelper.StripBrackets(Path.GetFileName(args.Path))
+ };
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs
new file mode 100644
index 000000000..1bec1073d
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs
@@ -0,0 +1,85 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ class SpecialFolderResolver : FolderResolver<Folder>
+ {
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationPaths _appPaths;
+
+ public SpecialFolderResolver(IFileSystem fileSystem, IServerApplicationPaths appPaths)
+ {
+ _fileSystem = fileSystem;
+ _appPaths = appPaths;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get { return ResolverPriority.First; }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Folder.</returns>
+ protected override Folder Resolve(ItemResolveArgs args)
+ {
+ if (args.IsDirectory)
+ {
+ if (args.IsPhysicalRoot)
+ {
+ return new AggregateFolder();
+ }
+ if (string.Equals(args.Path, _appPaths.DefaultUserViewsPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return new UserRootFolder(); //if we got here and still a root - must be user root
+ }
+ if (args.IsVf)
+ {
+ return new CollectionFolder
+ {
+ CollectionType = GetCollectionType(args),
+ PhysicalLocationsList = args.PhysicalLocations.ToList()
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private string GetCollectionType(ItemResolveArgs args)
+ {
+ return args.FileSystemChildren
+ .Where(i =>
+ {
+
+ try
+ {
+ return !i.IsDirectory &&
+ string.Equals(".collection", i.Extension, StringComparison.OrdinalIgnoreCase);
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+
+ })
+ .Select(i => _fileSystem.GetFileNameWithoutExtension(i))
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
new file mode 100644
index 000000000..2a4cc49b7
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
@@ -0,0 +1,75 @@
+using System;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using System.Linq;
+using MediaBrowser.Model.Entities;
+
+namespace Emby.Server.Implementations.Library.Resolvers.TV
+{
+ /// <summary>
+ /// Class EpisodeResolver
+ /// </summary>
+ public class EpisodeResolver : BaseVideoResolver<Episode>
+ {
+ public EpisodeResolver(ILibraryManager libraryManager) : base(libraryManager)
+ {
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Episode.</returns>
+ protected override Episode Resolve(ItemResolveArgs args)
+ {
+ var parent = args.Parent;
+
+ if (parent == null)
+ {
+ return null;
+ }
+
+ var season = parent as Season;
+ // Just in case the user decided to nest episodes.
+ // Not officially supported but in some cases we can handle it.
+ if (season == null)
+ {
+ season = parent.GetParents().OfType<Season>().FirstOrDefault();
+ }
+
+ // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something
+ // Also handle flat tv folders
+ if (season != null ||
+ string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) ||
+ args.HasParent<Series>())
+ {
+ var episode = ResolveVideo<Episode>(args, false);
+
+ if (episode != null)
+ {
+ var series = parent as Series;
+ if (series == null)
+ {
+ series = parent.GetParents().OfType<Series>().FirstOrDefault();
+ }
+
+ if (series != null)
+ {
+ episode.SeriesId = series.Id;
+ episode.SeriesName = series.Name;
+ episode.SeriesSortName = series.SortName;
+ }
+ if (season != null)
+ {
+ episode.SeasonId = season.Id;
+ episode.SeasonName = season.Name;
+ }
+ }
+
+ return episode;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
new file mode 100644
index 000000000..c065feda1
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -0,0 +1,62 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Naming.Common;
+using MediaBrowser.Naming.TV;
+
+namespace Emby.Server.Implementations.Library.Resolvers.TV
+{
+ /// <summary>
+ /// Class SeasonResolver
+ /// </summary>
+ public class SeasonResolver : FolderResolver<Season>
+ {
+ /// <summary>
+ /// The _config
+ /// </summary>
+ private readonly IServerConfigurationManager _config;
+
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeasonResolver"/> class.
+ /// </summary>
+ /// <param name="config">The config.</param>
+ public SeasonResolver(IServerConfigurationManager config, ILibraryManager libraryManager)
+ {
+ _config = config;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Season.</returns>
+ protected override Season Resolve(ItemResolveArgs args)
+ {
+ if (args.Parent is Series && args.IsDirectory)
+ {
+ var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions();
+ var series = ((Series)args.Parent);
+
+ var season = new Season
+ {
+ IndexNumber = new SeasonPathParser(namingOptions, new RegexProvider()).Parse(args.Path, true, true).SeasonNumber,
+ SeriesId = series.Id,
+ SeriesSortName = series.SortName,
+ SeriesName = series.Name
+ };
+
+ if (season.IndexNumber.HasValue && season.IndexNumber.Value == 0)
+ {
+ season.Name = _config.Configuration.SeasonZeroDisplayName;
+ }
+
+ return season;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
new file mode 100644
index 000000000..e5cad9f91
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
@@ -0,0 +1,250 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Naming.Common;
+using MediaBrowser.Naming.TV;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Configuration;
+
+namespace Emby.Server.Implementations.Library.Resolvers.TV
+{
+ /// <summary>
+ /// Class SeriesResolver
+ /// </summary>
+ public class SeriesResolver : FolderResolver<Series>
+ {
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+ private readonly ILibraryManager _libraryManager;
+
+ public SeriesResolver(IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get
+ {
+ return ResolverPriority.Second;
+ }
+ }
+
+ /// <summary>
+ /// Resolves the specified args.
+ /// </summary>
+ /// <param name="args">The args.</param>
+ /// <returns>Series.</returns>
+ protected override Series Resolve(ItemResolveArgs args)
+ {
+ if (args.IsDirectory)
+ {
+ if (args.HasParent<Series>() || args.HasParent<Season>())
+ {
+ return null;
+ }
+
+ var collectionType = args.GetCollectionType();
+ if (string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+ {
+ //if (args.ContainsFileSystemEntryByName("tvshow.nfo"))
+ //{
+ // return new Series
+ // {
+ // Path = args.Path,
+ // Name = Path.GetFileName(args.Path)
+ // };
+ //}
+
+ var configuredContentType = _libraryManager.GetConfiguredContentType(args.Path);
+ if (!string.Equals(configuredContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Series
+ {
+ Path = args.Path,
+ Name = Path.GetFileName(args.Path)
+ };
+ }
+ }
+ else if (string.IsNullOrWhiteSpace(collectionType))
+ {
+ if (args.ContainsFileSystemEntryByName("tvshow.nfo"))
+ {
+ if (args.Parent.IsRoot)
+ {
+ // For now, return null, but if we want to allow this in the future then add some additional checks to guard against a misplaced tvshow.nfo
+ return null;
+ }
+
+ return new Series
+ {
+ Path = args.Path,
+ Name = Path.GetFileName(args.Path)
+ };
+ }
+
+ if (args.Parent.IsRoot)
+ {
+ return null;
+ }
+
+ if (IsSeriesFolder(args.Path, args.FileSystemChildren, args.DirectoryService, _fileSystem, _logger, _libraryManager, args.GetLibraryOptions(), false))
+ {
+ return new Series
+ {
+ Path = args.Path,
+ Name = Path.GetFileName(args.Path)
+ };
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public static bool IsSeriesFolder(string path,
+ IEnumerable<FileSystemMetadata> fileSystemChildren,
+ IDirectoryService directoryService,
+ IFileSystem fileSystem,
+ ILogger logger,
+ ILibraryManager libraryManager,
+ LibraryOptions libraryOptions,
+ bool isTvContentType)
+ {
+ foreach (var child in fileSystemChildren)
+ {
+ //if ((attributes & FileAttributes.Hidden) == FileAttributes.Hidden)
+ //{
+ // //logger.Debug("Igoring series file or folder marked hidden: {0}", child.FullName);
+ // continue;
+ //}
+
+ // Can't enforce this because files saved by Bitcasa are always marked System
+ //if ((attributes & FileAttributes.System) == FileAttributes.System)
+ //{
+ // logger.Debug("Igoring series subfolder marked system: {0}", child.FullName);
+ // continue;
+ //}
+
+ if (child.IsDirectory)
+ {
+ if (IsSeasonFolder(child.FullName, isTvContentType, libraryManager))
+ {
+ //logger.Debug("{0} is a series because of season folder {1}.", path, child.FullName);
+ return true;
+ }
+ }
+ else
+ {
+ string fullName = child.FullName;
+ if (libraryManager.IsVideoFile(fullName, libraryOptions))
+ {
+ if (isTvContentType)
+ {
+ return true;
+ }
+
+ var namingOptions = ((LibraryManager)libraryManager).GetNamingOptions();
+
+ // In mixed folders we need to be conservative and avoid expressions that may result in false positives (e.g. movies with numbers in the title)
+ if (!isTvContentType)
+ {
+ namingOptions.EpisodeExpressions = namingOptions.EpisodeExpressions
+ .Where(i => i.IsNamed && !i.IsOptimistic)
+ .ToList();
+ }
+
+ var episodeResolver = new MediaBrowser.Naming.TV.EpisodeResolver(namingOptions, new NullLogger());
+ var episodeInfo = episodeResolver.Resolve(fullName, false, false);
+ if (episodeInfo != null && episodeInfo.EpisodeNumber.HasValue)
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ logger.Debug("{0} is not a series folder.", path);
+ return false;
+ }
+
+ /// <summary>
+ /// Determines whether [is place holder] [the specified path].
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns><c>true</c> if [is place holder] [the specified path]; otherwise, <c>false</c>.</returns>
+ /// <exception cref="System.ArgumentNullException">path</exception>
+ private static bool IsVideoPlaceHolder(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentNullException("path");
+ }
+
+ var extension = Path.GetExtension(path);
+
+ return string.Equals(extension, ".disc", StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// <summary>
+ /// Determines whether [is season folder] [the specified path].
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="isTvContentType">if set to <c>true</c> [is tv content type].</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <returns><c>true</c> if [is season folder] [the specified path]; otherwise, <c>false</c>.</returns>
+ private static bool IsSeasonFolder(string path, bool isTvContentType, ILibraryManager libraryManager)
+ {
+ var namingOptions = ((LibraryManager)libraryManager).GetNamingOptions();
+
+ var seasonNumber = new SeasonPathParser(namingOptions, new RegexProvider()).Parse(path, isTvContentType, isTvContentType).SeasonNumber;
+
+ return seasonNumber.HasValue;
+ }
+
+ /// <summary>
+ /// Sets the initial item values.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="args">The args.</param>
+ protected override void SetInitialItemValues(Series item, ItemResolveArgs args)
+ {
+ base.SetInitialItemValues(item, args);
+
+ SetProviderIdFromPath(item, args.Path);
+ }
+
+ /// <summary>
+ /// Sets the provider id from path.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="path">The path.</param>
+ private void SetProviderIdFromPath(Series item, string path)
+ {
+ var justName = Path.GetFileName(path);
+
+ var id = justName.GetAttributeValue("tvdbid");
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ item.SetProviderId(MetadataProviders.Tvdb, id);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs
new file mode 100644
index 000000000..b5e1bf5f7
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+
+namespace Emby.Server.Implementations.Library.Resolvers
+{
+ /// <summary>
+ /// Resolves a Path into a Video
+ /// </summary>
+ public class VideoResolver : BaseVideoResolver<Video>
+ {
+ public VideoResolver(ILibraryManager libraryManager)
+ : base(libraryManager)
+ {
+ }
+
+ protected override Video Resolve(ItemResolveArgs args)
+ {
+ if (args.Parent != null)
+ {
+ // The movie resolver will handle this
+ return null;
+ }
+
+ return base.Resolve(args);
+ }
+
+ /// <summary>
+ /// Gets the priority.
+ /// </summary>
+ /// <value>The priority.</value>
+ public override ResolverPriority Priority
+ {
+ get { return ResolverPriority.Last; }
+ }
+ }
+
+ public class GenericVideoResolver<T> : BaseVideoResolver<T>
+ where T : Video, new ()
+ {
+ public GenericVideoResolver(ILibraryManager libraryManager) : base(libraryManager)
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs
new file mode 100644
index 000000000..afdf65c06
--- /dev/null
+++ b/Emby.Server.Implementations/Library/SearchEngine.cs
@@ -0,0 +1,275 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Search;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Extensions;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// </summary>
+ public class SearchEngine : ISearchEngine
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+
+ public SearchEngine(ILogManager logManager, ILibraryManager libraryManager, IUserManager userManager)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+
+ _logger = logManager.GetLogger("Lucene");
+ }
+
+ public async Task<QueryResult<SearchHintInfo>> GetSearchHints(SearchQuery query)
+ {
+ User user = null;
+
+ if (string.IsNullOrWhiteSpace(query.UserId))
+ {
+ }
+ else
+ {
+ user = _userManager.GetUserById(query.UserId);
+ }
+
+ var results = await GetSearchHints(query, user).ConfigureAwait(false);
+
+ var searchResultArray = results.ToArray();
+ results = searchResultArray;
+
+ var count = searchResultArray.Length;
+
+ if (query.StartIndex.HasValue)
+ {
+ results = results.Skip(query.StartIndex.Value);
+ }
+
+ if (query.Limit.HasValue)
+ {
+ results = results.Take(query.Limit.Value);
+ }
+
+ return new QueryResult<SearchHintInfo>
+ {
+ TotalRecordCount = count,
+
+ Items = results.ToArray()
+ };
+ }
+
+ private void AddIfMissing(List<string> list, string value)
+ {
+ if (!list.Contains(value, StringComparer.OrdinalIgnoreCase))
+ {
+ list.Add(value);
+ }
+ }
+
+ /// <summary>
+ /// Gets the search hints.
+ /// </summary>
+ /// <param name="query">The query.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>IEnumerable{SearchHintResult}.</returns>
+ /// <exception cref="System.ArgumentNullException">searchTerm</exception>
+ private Task<IEnumerable<SearchHintInfo>> GetSearchHints(SearchQuery query, User user)
+ {
+ var searchTerm = query.SearchTerm;
+
+ if (searchTerm != null)
+ {
+ searchTerm = searchTerm.Trim().RemoveDiacritics();
+ }
+
+ if (string.IsNullOrWhiteSpace(searchTerm))
+ {
+ throw new ArgumentNullException("searchTerm");
+ }
+
+ var terms = GetWords(searchTerm);
+
+ var hints = new List<Tuple<BaseItem, string, int>>();
+
+ var excludeItemTypes = new List<string>();
+ var includeItemTypes = (query.IncludeItemTypes ?? new string[] { }).ToList();
+
+ excludeItemTypes.Add(typeof(Year).Name);
+ excludeItemTypes.Add(typeof(Folder).Name);
+
+ if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Genre", StringComparer.OrdinalIgnoreCase)))
+ {
+ if (!query.IncludeMedia)
+ {
+ AddIfMissing(includeItemTypes, typeof(Genre).Name);
+ AddIfMissing(includeItemTypes, typeof(GameGenre).Name);
+ AddIfMissing(includeItemTypes, typeof(MusicGenre).Name);
+ }
+ }
+ else
+ {
+ AddIfMissing(excludeItemTypes, typeof(Genre).Name);
+ AddIfMissing(excludeItemTypes, typeof(GameGenre).Name);
+ AddIfMissing(excludeItemTypes, typeof(MusicGenre).Name);
+ }
+
+ if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains("People", StringComparer.OrdinalIgnoreCase) || includeItemTypes.Contains("Person", StringComparer.OrdinalIgnoreCase)))
+ {
+ if (!query.IncludeMedia)
+ {
+ AddIfMissing(includeItemTypes, typeof(Person).Name);
+ }
+ }
+ else
+ {
+ AddIfMissing(excludeItemTypes, typeof(Person).Name);
+ }
+
+ if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Studio", StringComparer.OrdinalIgnoreCase)))
+ {
+ if (!query.IncludeMedia)
+ {
+ AddIfMissing(includeItemTypes, typeof(Studio).Name);
+ }
+ }
+ else
+ {
+ AddIfMissing(excludeItemTypes, typeof(Studio).Name);
+ }
+
+ if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase)))
+ {
+ if (!query.IncludeMedia)
+ {
+ AddIfMissing(includeItemTypes, typeof(MusicArtist).Name);
+ }
+ }
+ else
+ {
+ AddIfMissing(excludeItemTypes, typeof(MusicArtist).Name);
+ }
+
+ AddIfMissing(excludeItemTypes, typeof(CollectionFolder).Name);
+
+ var mediaItems = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ NameContains = searchTerm,
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ IncludeItemTypes = includeItemTypes.ToArray(),
+ Limit = query.Limit,
+ IncludeItemsByName = true,
+ IsVirtualItem = false
+ });
+
+ // Add search hints based on item name
+ hints.AddRange(mediaItems.Select(item =>
+ {
+ var index = GetIndex(item.Name, searchTerm, terms);
+
+ return new Tuple<BaseItem, string, int>(item, index.Item1, index.Item2);
+ }));
+
+ var returnValue = hints.Where(i => i.Item3 >= 0).OrderBy(i => i.Item3).Select(i => new SearchHintInfo
+ {
+ Item = i.Item1,
+ MatchedTerm = i.Item2
+ });
+
+ return Task.FromResult(returnValue);
+ }
+
+ /// <summary>
+ /// Gets the index.
+ /// </summary>
+ /// <param name="input">The input.</param>
+ /// <param name="searchInput">The search input.</param>
+ /// <param name="searchWords">The search input.</param>
+ /// <returns>System.Int32.</returns>
+ private Tuple<string, int> GetIndex(string input, string searchInput, List<string> searchWords)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ throw new ArgumentNullException("input");
+ }
+
+ input = input.RemoveDiacritics();
+
+ if (string.Equals(input, searchInput, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, int>(searchInput, 0);
+ }
+
+ var index = input.IndexOf(searchInput, StringComparison.OrdinalIgnoreCase);
+
+ if (index == 0)
+ {
+ return new Tuple<string, int>(searchInput, 1);
+ }
+ if (index > 0)
+ {
+ return new Tuple<string, int>(searchInput, 2);
+ }
+
+ var items = GetWords(input);
+
+ for (var i = 0; i < searchWords.Count; i++)
+ {
+ var searchTerm = searchWords[i];
+
+ for (var j = 0; j < items.Count; j++)
+ {
+ var item = items[j];
+
+ if (string.Equals(item, searchTerm, StringComparison.OrdinalIgnoreCase))
+ {
+ return new Tuple<string, int>(searchTerm, 3 + (i + 1) * (j + 1));
+ }
+
+ index = item.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
+
+ if (index == 0)
+ {
+ return new Tuple<string, int>(searchTerm, 4 + (i + 1) * (j + 1));
+ }
+ if (index > 0)
+ {
+ return new Tuple<string, int>(searchTerm, 5 + (i + 1) * (j + 1));
+ }
+ }
+ }
+ return new Tuple<string, int>(null, -1);
+ }
+
+ /// <summary>
+ /// Gets the words.
+ /// </summary>
+ /// <param name="term">The term.</param>
+ /// <returns>System.String[][].</returns>
+ private List<string> GetWords(string term)
+ {
+ var stoplist = GetStopList().ToList();
+
+ return term.Split()
+ .Where(i => !string.IsNullOrWhiteSpace(i) && !stoplist.Contains(i, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ private IEnumerable<string> GetStopList()
+ {
+ return new[]
+ {
+ "the",
+ "a",
+ "of",
+ "an"
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
new file mode 100644
index 000000000..5a14edf13
--- /dev/null
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -0,0 +1,288 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// Class UserDataManager
+ /// </summary>
+ public class UserDataManager : IUserDataManager
+ {
+ public event EventHandler<UserDataSaveEventArgs> UserDataSaved;
+
+ private readonly ConcurrentDictionary<string, UserItemData> _userData =
+ new ConcurrentDictionary<string, UserItemData>(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ILogger _logger;
+ private readonly IServerConfigurationManager _config;
+
+ public UserDataManager(ILogManager logManager, IServerConfigurationManager config)
+ {
+ _config = config;
+ _logger = logManager.GetLogger(GetType().Name);
+ }
+
+ /// <summary>
+ /// Gets or sets the repository.
+ /// </summary>
+ /// <value>The repository.</value>
+ public IUserDataRepository Repository { get; set; }
+
+ public async Task SaveUserData(Guid userId, IHasUserData item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken)
+ {
+ if (userData == null)
+ {
+ throw new ArgumentNullException("userData");
+ }
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var keys = item.GetUserDataKeys();
+
+ foreach (var key in keys)
+ {
+ await Repository.SaveUserData(userId, key, userData, cancellationToken).ConfigureAwait(false);
+ }
+
+ var cacheKey = GetCacheKey(userId, item.Id);
+ _userData.AddOrUpdate(cacheKey, userData, (k, v) => userData);
+
+ EventHelper.FireEventIfNotNull(UserDataSaved, this, new UserDataSaveEventArgs
+ {
+ Keys = keys,
+ UserData = userData,
+ SaveReason = reason,
+ UserId = userId,
+ Item = item
+
+ }, _logger);
+ }
+
+ /// <summary>
+ /// Save the provided user data for the given user. Batch operation. Does not fire any events or update the cache.
+ /// </summary>
+ /// <param name="userId"></param>
+ /// <param name="userData"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ public async Task SaveAllUserData(Guid userId, IEnumerable<UserItemData> userData, CancellationToken cancellationToken)
+ {
+ if (userData == null)
+ {
+ throw new ArgumentNullException("userData");
+ }
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await Repository.SaveAllUserData(userId, userData, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Retrieve all user data for the given user
+ /// </summary>
+ /// <param name="userId"></param>
+ /// <returns></returns>
+ public IEnumerable<UserItemData> GetAllUserData(Guid userId)
+ {
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ return Repository.GetAllUserData(userId);
+ }
+
+ public UserItemData GetUserData(Guid userId, Guid itemId, List<string> keys)
+ {
+ if (userId == Guid.Empty)
+ {
+ throw new ArgumentNullException("userId");
+ }
+ if (keys == null)
+ {
+ throw new ArgumentNullException("keys");
+ }
+ if (keys.Count == 0)
+ {
+ throw new ArgumentException("UserData keys cannot be empty.");
+ }
+
+ var cacheKey = GetCacheKey(userId, itemId);
+
+ return _userData.GetOrAdd(cacheKey, k => GetUserDataInternal(userId, keys));
+ }
+
+ private UserItemData GetUserDataInternal(Guid userId, List<string> keys)
+ {
+ var userData = Repository.GetUserData(userId, keys);
+
+ if (userData != null)
+ {
+ return userData;
+ }
+
+ if (keys.Count > 0)
+ {
+ return new UserItemData
+ {
+ UserId = userId,
+ Key = keys[0]
+ };
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Gets the internal key.
+ /// </summary>
+ /// <returns>System.String.</returns>
+ private string GetCacheKey(Guid userId, Guid itemId)
+ {
+ return userId.ToString("N") + itemId.ToString("N");
+ }
+
+ public UserItemData GetUserData(IHasUserData user, IHasUserData item)
+ {
+ return GetUserData(user.Id, item);
+ }
+
+ public UserItemData GetUserData(string userId, IHasUserData item)
+ {
+ return GetUserData(new Guid(userId), item);
+ }
+
+ public UserItemData GetUserData(Guid userId, IHasUserData item)
+ {
+ return GetUserData(userId, item.Id, item.GetUserDataKeys());
+ }
+
+ public async Task<UserItemDataDto> GetUserDataDto(IHasUserData item, User user)
+ {
+ var userData = GetUserData(user.Id, item);
+ var dto = GetUserItemDataDto(userData);
+
+ await item.FillUserDataDtoValues(dto, userData, null, user, new List<ItemFields>()).ConfigureAwait(false);
+ return dto;
+ }
+
+ public async Task<UserItemDataDto> GetUserDataDto(IHasUserData item, BaseItemDto itemDto, User user, List<ItemFields> fields)
+ {
+ var userData = GetUserData(user.Id, item);
+ var dto = GetUserItemDataDto(userData);
+
+ await item.FillUserDataDtoValues(dto, userData, itemDto, user, fields).ConfigureAwait(false);
+ return dto;
+ }
+
+ /// <summary>
+ /// Converts a UserItemData to a DTOUserItemData
+ /// </summary>
+ /// <param name="data">The data.</param>
+ /// <returns>DtoUserItemData.</returns>
+ /// <exception cref="System.ArgumentNullException"></exception>
+ private UserItemDataDto GetUserItemDataDto(UserItemData data)
+ {
+ if (data == null)
+ {
+ throw new ArgumentNullException("data");
+ }
+
+ return new UserItemDataDto
+ {
+ IsFavorite = data.IsFavorite,
+ Likes = data.Likes,
+ PlaybackPositionTicks = data.PlaybackPositionTicks,
+ PlayCount = data.PlayCount,
+ Rating = data.Rating,
+ Played = data.Played,
+ LastPlayedDate = data.LastPlayedDate,
+ Key = data.Key
+ };
+ }
+
+ public bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks)
+ {
+ var playedToCompletion = false;
+
+ var positionTicks = reportedPositionTicks ?? item.RunTimeTicks ?? 0;
+ var hasRuntime = item.RunTimeTicks.HasValue && item.RunTimeTicks > 0;
+
+ // If a position has been reported, and if we know the duration
+ if (positionTicks > 0 && hasRuntime)
+ {
+ var pctIn = Decimal.Divide(positionTicks, item.RunTimeTicks.Value) * 100;
+
+ // Don't track in very beginning
+ if (pctIn < _config.Configuration.MinResumePct)
+ {
+ positionTicks = 0;
+ }
+
+ // If we're at the end, assume completed
+ else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= item.RunTimeTicks.Value)
+ {
+ positionTicks = 0;
+ data.Played = playedToCompletion = true;
+ }
+
+ else
+ {
+ // Enforce MinResumeDuration
+ var durationSeconds = TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds;
+
+ if (durationSeconds < _config.Configuration.MinResumeDurationSeconds)
+ {
+ positionTicks = 0;
+ data.Played = playedToCompletion = true;
+ }
+ }
+ }
+ else if (!hasRuntime)
+ {
+ // If we don't know the runtime we'll just have to assume it was fully played
+ data.Played = playedToCompletion = true;
+ positionTicks = 0;
+ }
+
+ if (!item.SupportsPlayedStatus)
+ {
+ positionTicks = 0;
+ data.Played = false;
+ }
+ if (!item.SupportsPositionTicksResume)
+ {
+ positionTicks = 0;
+ }
+
+ data.PlaybackPositionTicks = positionTicks;
+
+ return playedToCompletion;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/UserManager.cs b/Emby.Server.Implementations/Library/UserManager.cs
new file mode 100644
index 000000000..2a5706b3b
--- /dev/null
+++ b/Emby.Server.Implementations/Library/UserManager.cs
@@ -0,0 +1,1029 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Connect;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Connect;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Users;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// Class UserManager
+ /// </summary>
+ public class UserManager : IUserManager
+ {
+ /// <summary>
+ /// Gets the users.
+ /// </summary>
+ /// <value>The users.</value>
+ public IEnumerable<User> Users { get; private set; }
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Gets or sets the configuration manager.
+ /// </summary>
+ /// <value>The configuration manager.</value>
+ private IServerConfigurationManager ConfigurationManager { get; set; }
+
+ /// <summary>
+ /// Gets the active user repository
+ /// </summary>
+ /// <value>The user repository.</value>
+ private IUserRepository UserRepository { get; set; }
+ public event EventHandler<GenericEventArgs<User>> UserPasswordChanged;
+
+ private readonly IXmlSerializer _xmlSerializer;
+ private readonly IJsonSerializer _jsonSerializer;
+
+ private readonly INetworkManager _networkManager;
+
+ private readonly Func<IImageProcessor> _imageProcessorFactory;
+ private readonly Func<IDtoService> _dtoServiceFactory;
+ private readonly Func<IConnectManager> _connectFactory;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IFileSystem _fileSystem;
+ private readonly ICryptoProvider _cryptographyProvider;
+ private readonly string _defaultUserName;
+
+ public UserManager(ILogger logger, IServerConfigurationManager configurationManager, IUserRepository userRepository, IXmlSerializer xmlSerializer, INetworkManager networkManager, Func<IImageProcessor> imageProcessorFactory, Func<IDtoService> dtoServiceFactory, Func<IConnectManager> connectFactory, IServerApplicationHost appHost, IJsonSerializer jsonSerializer, IFileSystem fileSystem, ICryptoProvider cryptographyProvider, string defaultUserName)
+ {
+ _logger = logger;
+ UserRepository = userRepository;
+ _xmlSerializer = xmlSerializer;
+ _networkManager = networkManager;
+ _imageProcessorFactory = imageProcessorFactory;
+ _dtoServiceFactory = dtoServiceFactory;
+ _connectFactory = connectFactory;
+ _appHost = appHost;
+ _jsonSerializer = jsonSerializer;
+ _fileSystem = fileSystem;
+ _cryptographyProvider = cryptographyProvider;
+ _defaultUserName = defaultUserName;
+ ConfigurationManager = configurationManager;
+ Users = new List<User>();
+
+ DeletePinFile();
+ }
+
+ #region UserUpdated Event
+ /// <summary>
+ /// Occurs when [user updated].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<User>> UserUpdated;
+ public event EventHandler<GenericEventArgs<User>> UserConfigurationUpdated;
+ public event EventHandler<GenericEventArgs<User>> UserLockedOut;
+
+ /// <summary>
+ /// Called when [user updated].
+ /// </summary>
+ /// <param name="user">The user.</param>
+ private void OnUserUpdated(User user)
+ {
+ EventHelper.FireEventIfNotNull(UserUpdated, this, new GenericEventArgs<User> { Argument = user }, _logger);
+ }
+ #endregion
+
+ #region UserDeleted Event
+ /// <summary>
+ /// Occurs when [user deleted].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<User>> UserDeleted;
+ /// <summary>
+ /// Called when [user deleted].
+ /// </summary>
+ /// <param name="user">The user.</param>
+ private void OnUserDeleted(User user)
+ {
+ EventHelper.QueueEventIfNotNull(UserDeleted, this, new GenericEventArgs<User> { Argument = user }, _logger);
+ }
+ #endregion
+
+ /// <summary>
+ /// Gets a User by Id
+ /// </summary>
+ /// <param name="id">The id.</param>
+ /// <returns>User.</returns>
+ /// <exception cref="System.ArgumentNullException"></exception>
+ public User GetUserById(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ return Users.FirstOrDefault(u => u.Id == id);
+ }
+
+ /// <summary>
+ /// Gets the user by identifier.
+ /// </summary>
+ /// <param name="id">The identifier.</param>
+ /// <returns>User.</returns>
+ public User GetUserById(string id)
+ {
+ return GetUserById(new Guid(id));
+ }
+
+ public User GetUserByName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+
+ return Users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public async Task Initialize()
+ {
+ Users = await LoadUsers().ConfigureAwait(false);
+
+ var users = Users.ToList();
+
+ // If there are no local users with admin rights, make them all admins
+ if (!users.Any(i => i.Policy.IsAdministrator))
+ {
+ foreach (var user in users)
+ {
+ if (!user.ConnectLinkType.HasValue || user.ConnectLinkType.Value == UserLinkType.LinkedUser)
+ {
+ user.Policy.IsAdministrator = true;
+ await UpdateUserPolicy(user, user.Policy, false).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+
+ public Task<bool> AuthenticateUser(string username, string passwordSha1, string remoteEndPoint)
+ {
+ return AuthenticateUser(username, passwordSha1, null, remoteEndPoint);
+ }
+
+ public bool IsValidUsername(string username)
+ {
+ // Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)
+ foreach (var currentChar in username)
+ {
+ if (!IsValidUsernameCharacter(currentChar))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private bool IsValidUsernameCharacter(char i)
+ {
+ return char.IsLetterOrDigit(i) || char.Equals(i, '-') || char.Equals(i, '_') || char.Equals(i, '\'') ||
+ char.Equals(i, '.');
+ }
+
+ public string MakeValidUsername(string username)
+ {
+ if (IsValidUsername(username))
+ {
+ return username;
+ }
+
+ // Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)
+ var builder = new StringBuilder();
+
+ foreach (var c in username)
+ {
+ if (IsValidUsernameCharacter(c))
+ {
+ builder.Append(c);
+ }
+ }
+ return builder.ToString();
+ }
+
+ public async Task<bool> AuthenticateUser(string username, string passwordSha1, string passwordMd5, string remoteEndPoint)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new ArgumentNullException("username");
+ }
+
+ var user = Users
+ .FirstOrDefault(i => string.Equals(username, i.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (user == null)
+ {
+ throw new SecurityException("Invalid username or password entered.");
+ }
+
+ if (user.Policy.IsDisabled)
+ {
+ throw new SecurityException(string.Format("The {0} account is currently disabled. Please consult with your administrator.", user.Name));
+ }
+
+ var success = false;
+
+ // Authenticate using local credentials if not a guest
+ if (!user.ConnectLinkType.HasValue || user.ConnectLinkType.Value != UserLinkType.Guest)
+ {
+ success = string.Equals(GetPasswordHash(user), passwordSha1.Replace("-", string.Empty), StringComparison.OrdinalIgnoreCase);
+
+ if (!success && _networkManager.IsInLocalNetwork(remoteEndPoint) && user.Configuration.EnableLocalPassword)
+ {
+ success = string.Equals(GetLocalPasswordHash(user), passwordSha1.Replace("-", string.Empty), StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ // Update LastActivityDate and LastLoginDate, then save
+ if (success)
+ {
+ user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
+ await UpdateUser(user).ConfigureAwait(false);
+ await UpdateInvalidLoginAttemptCount(user, 0).ConfigureAwait(false);
+ }
+ else
+ {
+ await UpdateInvalidLoginAttemptCount(user, user.Policy.InvalidLoginAttemptCount + 1).ConfigureAwait(false);
+ }
+
+ _logger.Info("Authentication request for {0} {1}.", user.Name, success ? "has succeeded" : "has been denied");
+
+ return success;
+ }
+
+ private async Task UpdateInvalidLoginAttemptCount(User user, int newValue)
+ {
+ if (user.Policy.InvalidLoginAttemptCount != newValue || newValue > 0)
+ {
+ user.Policy.InvalidLoginAttemptCount = newValue;
+
+ var maxCount = user.Policy.IsAdministrator ?
+ 3 :
+ 5;
+
+ var fireLockout = false;
+
+ if (newValue >= maxCount)
+ {
+ //_logger.Debug("Disabling user {0} due to {1} unsuccessful login attempts.", user.Name, newValue.ToString(CultureInfo.InvariantCulture));
+ //user.Policy.IsDisabled = true;
+
+ //fireLockout = true;
+ }
+
+ await UpdateUserPolicy(user, user.Policy, false).ConfigureAwait(false);
+
+ if (fireLockout)
+ {
+ if (UserLockedOut != null)
+ {
+ EventHelper.FireEventIfNotNull(UserLockedOut, this, new GenericEventArgs<User>(user), _logger);
+ }
+ }
+ }
+ }
+
+ private string GetPasswordHash(User user)
+ {
+ return string.IsNullOrEmpty(user.Password)
+ ? GetSha1String(string.Empty)
+ : user.Password;
+ }
+
+ private string GetLocalPasswordHash(User user)
+ {
+ return string.IsNullOrEmpty(user.EasyPassword)
+ ? GetSha1String(string.Empty)
+ : user.EasyPassword;
+ }
+
+ private bool IsPasswordEmpty(string passwordHash)
+ {
+ return string.Equals(passwordHash, GetSha1String(string.Empty), StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the sha1 string.
+ /// </summary>
+ /// <param name="str">The STR.</param>
+ /// <returns>System.String.</returns>
+ private string GetSha1String(string str)
+ {
+ return BitConverter.ToString(_cryptographyProvider.ComputeSHA1(Encoding.UTF8.GetBytes(str))).Replace("-", string.Empty);
+ }
+
+ /// <summary>
+ /// Loads the users from the repository
+ /// </summary>
+ /// <returns>IEnumerable{User}.</returns>
+ private async Task<IEnumerable<User>> LoadUsers()
+ {
+ var users = UserRepository.RetrieveAllUsers().ToList();
+
+ // There always has to be at least one user.
+ if (users.Count == 0)
+ {
+ var name = MakeValidUsername(_defaultUserName);
+
+ var user = InstantiateNewUser(name);
+
+ user.DateLastSaved = DateTime.UtcNow;
+
+ await UserRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false);
+
+ users.Add(user);
+
+ user.Policy.IsAdministrator = true;
+ user.Policy.EnableContentDeletion = true;
+ user.Policy.EnableRemoteControlOfOtherUsers = true;
+ await UpdateUserPolicy(user, user.Policy, false).ConfigureAwait(false);
+ }
+
+ return users;
+ }
+
+ public UserDto GetUserDto(User user, string remoteEndPoint = null)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ var passwordHash = GetPasswordHash(user);
+
+ var hasConfiguredPassword = !IsPasswordEmpty(passwordHash);
+ var hasConfiguredEasyPassword = !IsPasswordEmpty(GetLocalPasswordHash(user));
+
+ var hasPassword = user.Configuration.EnableLocalPassword && !string.IsNullOrEmpty(remoteEndPoint) && _networkManager.IsInLocalNetwork(remoteEndPoint) ?
+ hasConfiguredEasyPassword :
+ hasConfiguredPassword;
+
+ var dto = new UserDto
+ {
+ Id = user.Id.ToString("N"),
+ Name = user.Name,
+ HasPassword = hasPassword,
+ HasConfiguredPassword = hasConfiguredPassword,
+ HasConfiguredEasyPassword = hasConfiguredEasyPassword,
+ LastActivityDate = user.LastActivityDate,
+ LastLoginDate = user.LastLoginDate,
+ Configuration = user.Configuration,
+ ConnectLinkType = user.ConnectLinkType,
+ ConnectUserId = user.ConnectUserId,
+ ConnectUserName = user.ConnectUserName,
+ ServerId = _appHost.SystemId,
+ Policy = user.Policy
+ };
+
+ var image = user.GetImageInfo(ImageType.Primary, 0);
+
+ if (image != null)
+ {
+ dto.PrimaryImageTag = GetImageCacheTag(user, image);
+
+ try
+ {
+ _dtoServiceFactory().AttachPrimaryImageAspectRatio(dto, user);
+ }
+ catch (Exception ex)
+ {
+ // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions
+ _logger.ErrorException("Error generating PrimaryImageAspectRatio for {0}", ex, user.Name);
+ }
+ }
+
+ return dto;
+ }
+
+ public UserDto GetOfflineUserDto(User user)
+ {
+ var dto = GetUserDto(user);
+
+ var offlinePasswordHash = GetLocalPasswordHash(user);
+ dto.HasPassword = !IsPasswordEmpty(offlinePasswordHash);
+
+ dto.OfflinePasswordSalt = Guid.NewGuid().ToString("N");
+
+ // Hash the pin with the device Id to create a unique result for this device
+ dto.OfflinePassword = GetSha1String((offlinePasswordHash + dto.OfflinePasswordSalt).ToLower());
+
+ dto.ServerName = _appHost.FriendlyName;
+
+ return dto;
+ }
+
+ private string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+ {
+ try
+ {
+ return _imageProcessorFactory().GetImageCacheTag(item, image);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting {0} image info for {1}", ex, image.Type, image.Path);
+ return null;
+ }
+ }
+
+ /// <summary>
+ /// Refreshes metadata for each user
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task RefreshUsersMetadata(CancellationToken cancellationToken)
+ {
+ var tasks = Users.Select(user => user.RefreshMetadata(new MetadataRefreshOptions(_fileSystem), cancellationToken)).ToList();
+
+ return Task.WhenAll(tasks);
+ }
+
+ /// <summary>
+ /// Renames the user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="newName">The new name.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ /// <exception cref="System.ArgumentException"></exception>
+ public async Task RenameUser(User user, string newName)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ if (string.IsNullOrEmpty(newName))
+ {
+ throw new ArgumentNullException("newName");
+ }
+
+ if (Users.Any(u => u.Id != user.Id && u.Name.Equals(newName, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", newName));
+ }
+
+ if (user.Name.Equals(newName, StringComparison.Ordinal))
+ {
+ throw new ArgumentException("The new and old names must be different.");
+ }
+
+ await user.Rename(newName);
+
+ OnUserUpdated(user);
+ }
+
+ /// <summary>
+ /// Updates the user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ /// <exception cref="System.ArgumentException"></exception>
+ public async Task UpdateUser(User user)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ if (user.Id == Guid.Empty || !Users.Any(u => u.Id.Equals(user.Id)))
+ {
+ throw new ArgumentException(string.Format("User with name '{0}' and Id {1} does not exist.", user.Name, user.Id));
+ }
+
+ user.DateModified = DateTime.UtcNow;
+ user.DateLastSaved = DateTime.UtcNow;
+
+ await UserRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false);
+
+ OnUserUpdated(user);
+ }
+
+ public event EventHandler<GenericEventArgs<User>> UserCreated;
+
+ private readonly SemaphoreSlim _userListLock = new SemaphoreSlim(1, 1);
+
+ /// <summary>
+ /// Creates the user.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>User.</returns>
+ /// <exception cref="System.ArgumentNullException">name</exception>
+ /// <exception cref="System.ArgumentException"></exception>
+ public async Task<User> CreateUser(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException("name");
+ }
+
+ if (!IsValidUsername(name))
+ {
+ throw new ArgumentException("Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)");
+ }
+
+ if (Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", name));
+ }
+
+ await _userListLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ var user = InstantiateNewUser(name);
+
+ var list = Users.ToList();
+ list.Add(user);
+ Users = list;
+
+ user.DateLastSaved = DateTime.UtcNow;
+
+ await UserRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false);
+
+ EventHelper.QueueEventIfNotNull(UserCreated, this, new GenericEventArgs<User> { Argument = user }, _logger);
+
+ return user;
+ }
+ finally
+ {
+ _userListLock.Release();
+ }
+ }
+
+ /// <summary>
+ /// Deletes the user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ /// <exception cref="System.ArgumentException"></exception>
+ public async Task DeleteUser(User user)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+
+ if (user.ConnectLinkType.HasValue)
+ {
+ await _connectFactory().RemoveConnect(user.Id.ToString("N")).ConfigureAwait(false);
+ }
+
+ var allUsers = Users.ToList();
+
+ if (allUsers.FirstOrDefault(u => u.Id == user.Id) == null)
+ {
+ throw new ArgumentException(string.Format("The user cannot be deleted because there is no user with the Name {0} and Id {1}.", user.Name, user.Id));
+ }
+
+ if (allUsers.Count == 1)
+ {
+ throw new ArgumentException(string.Format("The user '{0}' cannot be deleted because there must be at least one user in the system.", user.Name));
+ }
+
+ if (user.Policy.IsAdministrator && allUsers.Count(i => i.Policy.IsAdministrator) == 1)
+ {
+ throw new ArgumentException(string.Format("The user '{0}' cannot be deleted because there must be at least one admin user in the system.", user.Name));
+ }
+
+ await _userListLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ var configPath = GetConfigurationFilePath(user);
+
+ await UserRepository.DeleteUser(user, CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ _fileSystem.DeleteFile(configPath);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error deleting file {0}", ex, configPath);
+ }
+
+ DeleteUserPolicy(user);
+
+ // Force this to be lazy loaded again
+ Users = await LoadUsers().ConfigureAwait(false);
+
+ OnUserDeleted(user);
+ }
+ finally
+ {
+ _userListLock.Release();
+ }
+ }
+
+ /// <summary>
+ /// Resets the password by clearing it.
+ /// </summary>
+ /// <returns>Task.</returns>
+ public Task ResetPassword(User user)
+ {
+ return ChangePassword(user, GetSha1String(string.Empty));
+ }
+
+ public Task ResetEasyPassword(User user)
+ {
+ return ChangeEasyPassword(user, GetSha1String(string.Empty));
+ }
+
+ public async Task ChangePassword(User user, string newPasswordSha1)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+ if (string.IsNullOrWhiteSpace(newPasswordSha1))
+ {
+ throw new ArgumentNullException("newPasswordSha1");
+ }
+
+ if (user.ConnectLinkType.HasValue && user.ConnectLinkType.Value == UserLinkType.Guest)
+ {
+ throw new ArgumentException("Passwords for guests cannot be changed.");
+ }
+
+ user.Password = newPasswordSha1;
+
+ await UpdateUser(user).ConfigureAwait(false);
+
+ EventHelper.FireEventIfNotNull(UserPasswordChanged, this, new GenericEventArgs<User>(user), _logger);
+ }
+
+ public async Task ChangeEasyPassword(User user, string newPasswordSha1)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException("user");
+ }
+ if (string.IsNullOrWhiteSpace(newPasswordSha1))
+ {
+ throw new ArgumentNullException("newPasswordSha1");
+ }
+
+ user.EasyPassword = newPasswordSha1;
+
+ await UpdateUser(user).ConfigureAwait(false);
+
+ EventHelper.FireEventIfNotNull(UserPasswordChanged, this, new GenericEventArgs<User>(user), _logger);
+ }
+
+ /// <summary>
+ /// Instantiates the new user.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <returns>User.</returns>
+ private User InstantiateNewUser(string name)
+ {
+ return new User
+ {
+ Name = name,
+ Id = Guid.NewGuid(),
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ UsesIdForConfigurationPath = true
+ };
+ }
+
+ private string PasswordResetFile
+ {
+ get { return Path.Combine(ConfigurationManager.ApplicationPaths.ProgramDataPath, "passwordreset.txt"); }
+ }
+
+ private string _lastPin;
+ private PasswordPinCreationResult _lastPasswordPinCreationResult;
+ private int _pinAttempts;
+
+ private PasswordPinCreationResult CreatePasswordResetPin()
+ {
+ var num = new Random().Next(1, 9999);
+
+ var path = PasswordResetFile;
+
+ var pin = num.ToString("0000", CultureInfo.InvariantCulture);
+ _lastPin = pin;
+
+ var time = TimeSpan.FromMinutes(5);
+ var expiration = DateTime.UtcNow.Add(time);
+
+ var text = new StringBuilder();
+
+ var localAddress = _appHost.GetLocalApiUrl().Result ?? string.Empty;
+
+ text.AppendLine("Use your web browser to visit:");
+ text.AppendLine(string.Empty);
+ text.AppendLine(localAddress + "/web/forgotpasswordpin.html");
+ text.AppendLine(string.Empty);
+ text.AppendLine("Enter the following pin code:");
+ text.AppendLine(string.Empty);
+ text.AppendLine(pin);
+ text.AppendLine(string.Empty);
+
+ var localExpirationTime = expiration.ToLocalTime();
+ // Tuesday, 22 August 2006 06:30 AM
+ text.AppendLine("The pin code will expire at " + localExpirationTime.ToString("f1", CultureInfo.CurrentCulture));
+
+ _fileSystem.WriteAllText(path, text.ToString(), Encoding.UTF8);
+
+ var result = new PasswordPinCreationResult
+ {
+ PinFile = path,
+ ExpirationDate = expiration
+ };
+
+ _lastPasswordPinCreationResult = result;
+ _pinAttempts = 0;
+
+ return result;
+ }
+
+ public ForgotPasswordResult StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
+ {
+ DeletePinFile();
+
+ var user = string.IsNullOrWhiteSpace(enteredUsername) ?
+ null :
+ GetUserByName(enteredUsername);
+
+ if (user != null && user.ConnectLinkType.HasValue && user.ConnectLinkType.Value == UserLinkType.Guest)
+ {
+ throw new ArgumentException("Unable to process forgot password request for guests.");
+ }
+
+ var action = ForgotPasswordAction.InNetworkRequired;
+ string pinFile = null;
+ DateTime? expirationDate = null;
+
+ if (user != null && !user.Policy.IsAdministrator)
+ {
+ action = ForgotPasswordAction.ContactAdmin;
+ }
+ else
+ {
+ if (isInNetwork)
+ {
+ action = ForgotPasswordAction.PinCode;
+ }
+
+ var result = CreatePasswordResetPin();
+ pinFile = result.PinFile;
+ expirationDate = result.ExpirationDate;
+ }
+
+ return new ForgotPasswordResult
+ {
+ Action = action,
+ PinFile = pinFile,
+ PinExpirationDate = expirationDate
+ };
+ }
+
+ public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
+ {
+ DeletePinFile();
+
+ var usersReset = new List<string>();
+
+ var valid = !string.IsNullOrWhiteSpace(_lastPin) &&
+ string.Equals(_lastPin, pin, StringComparison.OrdinalIgnoreCase) &&
+ _lastPasswordPinCreationResult != null &&
+ _lastPasswordPinCreationResult.ExpirationDate > DateTime.UtcNow;
+
+ if (valid)
+ {
+ _lastPin = null;
+ _lastPasswordPinCreationResult = null;
+
+ var users = Users.Where(i => !i.ConnectLinkType.HasValue || i.ConnectLinkType.Value != UserLinkType.Guest)
+ .ToList();
+
+ foreach (var user in users)
+ {
+ await ResetPassword(user).ConfigureAwait(false);
+
+ if (user.Policy.IsDisabled)
+ {
+ user.Policy.IsDisabled = false;
+ await UpdateUserPolicy(user, user.Policy, true).ConfigureAwait(false);
+ }
+ usersReset.Add(user.Name);
+ }
+ }
+ else
+ {
+ _pinAttempts++;
+ if (_pinAttempts >= 3)
+ {
+ _lastPin = null;
+ _lastPasswordPinCreationResult = null;
+ }
+ }
+
+ return new PinRedeemResult
+ {
+ Success = valid,
+ UsersReset = usersReset.ToArray()
+ };
+ }
+
+ private void DeletePinFile()
+ {
+ try
+ {
+ _fileSystem.DeleteFile(PasswordResetFile);
+ }
+ catch
+ {
+
+ }
+ }
+
+ class PasswordPinCreationResult
+ {
+ public string PinFile { get; set; }
+ public DateTime ExpirationDate { get; set; }
+ }
+
+ public UserPolicy GetUserPolicy(User user)
+ {
+ var path = GetPolifyFilePath(user);
+
+ try
+ {
+ lock (_policySyncLock)
+ {
+ return (UserPolicy)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), path);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+ return GetDefaultPolicy(user);
+ }
+ catch (IOException)
+ {
+ return GetDefaultPolicy(user);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reading policy file: {0}", ex, path);
+
+ return GetDefaultPolicy(user);
+ }
+ }
+
+ private UserPolicy GetDefaultPolicy(User user)
+ {
+ return new UserPolicy
+ {
+ EnableSync = true
+ };
+ }
+
+ private readonly object _policySyncLock = new object();
+ public Task UpdateUserPolicy(string userId, UserPolicy userPolicy)
+ {
+ var user = GetUserById(userId);
+ return UpdateUserPolicy(user, userPolicy, true);
+ }
+
+ private async Task UpdateUserPolicy(User user, UserPolicy userPolicy, bool fireEvent)
+ {
+ // The xml serializer will output differently if the type is not exact
+ if (userPolicy.GetType() != typeof(UserPolicy))
+ {
+ var json = _jsonSerializer.SerializeToString(userPolicy);
+ userPolicy = _jsonSerializer.DeserializeFromString<UserPolicy>(json);
+ }
+
+ var path = GetPolifyFilePath(user);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ lock (_policySyncLock)
+ {
+ _xmlSerializer.SerializeToFile(userPolicy, path);
+ user.Policy = userPolicy;
+ }
+
+ await UpdateConfiguration(user, user.Configuration, true).ConfigureAwait(false);
+ }
+
+ private void DeleteUserPolicy(User user)
+ {
+ var path = GetPolifyFilePath(user);
+
+ try
+ {
+ lock (_policySyncLock)
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ }
+ catch (IOException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting policy file", ex);
+ }
+ }
+
+ private string GetPolifyFilePath(User user)
+ {
+ return Path.Combine(user.ConfigurationDirectoryPath, "policy.xml");
+ }
+
+ private string GetConfigurationFilePath(User user)
+ {
+ return Path.Combine(user.ConfigurationDirectoryPath, "config.xml");
+ }
+
+ public UserConfiguration GetUserConfiguration(User user)
+ {
+ var path = GetConfigurationFilePath(user);
+
+ try
+ {
+ lock (_configSyncLock)
+ {
+ return (UserConfiguration)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), path);
+ }
+ }
+ catch (FileNotFoundException)
+ {
+ return new UserConfiguration();
+ }
+ catch (IOException)
+ {
+ return new UserConfiguration();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reading policy file: {0}", ex, path);
+
+ return new UserConfiguration();
+ }
+ }
+
+ private readonly object _configSyncLock = new object();
+ public Task UpdateConfiguration(string userId, UserConfiguration config)
+ {
+ var user = GetUserById(userId);
+ return UpdateConfiguration(user, config, true);
+ }
+
+ private async Task UpdateConfiguration(User user, UserConfiguration config, bool fireEvent)
+ {
+ var path = GetConfigurationFilePath(user);
+
+ // The xml serializer will output differently if the type is not exact
+ if (config.GetType() != typeof(UserConfiguration))
+ {
+ var json = _jsonSerializer.SerializeToString(config);
+ config = _jsonSerializer.DeserializeFromString<UserConfiguration>(json);
+ }
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ lock (_configSyncLock)
+ {
+ _xmlSerializer.SerializeToFile(config, path);
+ user.Configuration = config;
+ }
+
+ if (fireEvent)
+ {
+ EventHelper.FireEventIfNotNull(UserConfigurationUpdated, this, new GenericEventArgs<User> { Argument = user }, _logger);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
new file mode 100644
index 000000000..f7cc8bb73
--- /dev/null
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -0,0 +1,298 @@
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Querying;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Server.Implementations.Library
+{
+ public class UserViewManager : IUserViewManager
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IUserManager _userManager;
+
+ private readonly IChannelManager _channelManager;
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IServerConfigurationManager _config;
+
+ public UserViewManager(ILibraryManager libraryManager, ILocalizationManager localizationManager, IUserManager userManager, IChannelManager channelManager, ILiveTvManager liveTvManager, IServerConfigurationManager config)
+ {
+ _libraryManager = libraryManager;
+ _localizationManager = localizationManager;
+ _userManager = userManager;
+ _channelManager = channelManager;
+ _liveTvManager = liveTvManager;
+ _config = config;
+ }
+
+ public async Task<IEnumerable<Folder>> GetUserViews(UserViewQuery query, CancellationToken cancellationToken)
+ {
+ var user = _userManager.GetUserById(query.UserId);
+
+ var folders = user.RootFolder
+ .GetChildren(user, true)
+ .OfType<Folder>()
+ .ToList();
+
+ if (!query.IncludeHidden)
+ {
+ folders = folders.Where(i =>
+ {
+ var hidden = i as IHiddenFromDisplay;
+ return hidden == null || !hidden.IsHiddenFromUser(user);
+ }).ToList();
+ }
+
+ var plainFolderIds = user.Configuration.PlainFolderViews.Select(i => new Guid(i)).ToList();
+
+ var groupedFolders = new List<ICollectionFolder>();
+
+ var list = new List<Folder>();
+
+ foreach (var folder in folders)
+ {
+ var collectionFolder = folder as ICollectionFolder;
+ var folderViewType = collectionFolder == null ? null : collectionFolder.CollectionType;
+
+ if (UserView.IsUserSpecific(folder))
+ {
+ list.Add(await _libraryManager.GetNamedView(user, folder.Name, folder.Id.ToString("N"), folderViewType, null, cancellationToken).ConfigureAwait(false));
+ continue;
+ }
+
+ if (plainFolderIds.Contains(folder.Id) && UserView.IsEligibleForEnhancedView(folderViewType))
+ {
+ list.Add(folder);
+ continue;
+ }
+
+ if (collectionFolder != null && UserView.IsEligibleForGrouping(folder) && user.IsFolderGrouped(folder.Id))
+ {
+ groupedFolders.Add(collectionFolder);
+ continue;
+ }
+
+ if (query.PresetViews.Contains(folderViewType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+ {
+ list.Add(await GetUserView(folder, folderViewType, string.Empty, cancellationToken).ConfigureAwait(false));
+ }
+ else
+ {
+ list.Add(folder);
+ }
+ }
+
+ foreach (var viewType in new[] { CollectionType.Movies, CollectionType.TvShows })
+ {
+ var parents = groupedFolders.Where(i => string.Equals(i.CollectionType, viewType, StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(i.CollectionType))
+ .ToList();
+
+ if (parents.Count > 0)
+ {
+ list.Add(await GetUserView(parents, viewType, string.Empty, user, query.PresetViews, cancellationToken).ConfigureAwait(false));
+ }
+ }
+
+ if (_config.Configuration.EnableFolderView)
+ {
+ var name = _localizationManager.GetLocalizedString("ViewType" + CollectionType.Folders);
+ list.Add(await _libraryManager.GetNamedView(name, CollectionType.Folders, string.Empty, cancellationToken).ConfigureAwait(false));
+ }
+
+ if (query.IncludeExternalContent)
+ {
+ var channelResult = await _channelManager.GetChannelsInternal(new ChannelQuery
+ {
+ UserId = query.UserId
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ var channels = channelResult.Items;
+
+ if (_config.Configuration.EnableChannelView && channels.Length > 0)
+ {
+ list.Add(await _channelManager.GetInternalChannelFolder(cancellationToken).ConfigureAwait(false));
+ }
+ else
+ {
+ list.AddRange(channels);
+ }
+
+ if (_liveTvManager.GetEnabledUsers().Select(i => i.Id.ToString("N")).Contains(query.UserId))
+ {
+ list.Add(await _liveTvManager.GetInternalLiveTvFolder(CancellationToken.None).ConfigureAwait(false));
+ }
+ }
+
+ var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
+
+ var orders = user.Configuration.OrderedViews.ToList();
+
+ return list
+ .OrderBy(i =>
+ {
+ var index = orders.IndexOf(i.Id.ToString("N"));
+
+ if (index == -1)
+ {
+ var view = i as UserView;
+ if (view != null)
+ {
+ if (view.DisplayParentId != Guid.Empty)
+ {
+ index = orders.IndexOf(view.DisplayParentId.ToString("N"));
+ }
+ }
+ }
+
+ return index == -1 ? int.MaxValue : index;
+ })
+ .ThenBy(sorted.IndexOf)
+ .ThenBy(i => i.SortName);
+ }
+
+ public Task<UserView> GetUserSubView(string name, string parentId, string type, string sortName, CancellationToken cancellationToken)
+ {
+ var uniqueId = parentId + "subview" + type;
+
+ return _libraryManager.GetNamedView(name, parentId, type, sortName, uniqueId, cancellationToken);
+ }
+
+ public Task<UserView> GetUserSubView(string parentId, string type, string sortName, CancellationToken cancellationToken)
+ {
+ var name = _localizationManager.GetLocalizedString("ViewType" + type);
+
+ return GetUserSubView(name, parentId, type, sortName, cancellationToken);
+ }
+
+ private async Task<Folder> GetUserView(List<ICollectionFolder> parents, string viewType, string sortName, User user, string[] presetViews, CancellationToken cancellationToken)
+ {
+ if (parents.Count == 1 && parents.All(i => string.Equals(i.CollectionType, viewType, StringComparison.OrdinalIgnoreCase)))
+ {
+ if (!presetViews.Contains(viewType, StringComparer.OrdinalIgnoreCase))
+ {
+ return (Folder)parents[0];
+ }
+
+ return await GetUserView((Folder)parents[0], viewType, string.Empty, cancellationToken).ConfigureAwait(false);
+ }
+
+ var name = _localizationManager.GetLocalizedString("ViewType" + viewType);
+ return await _libraryManager.GetNamedView(user, name, viewType, sortName, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task<UserView> GetUserView(Folder parent, string viewType, string sortName, CancellationToken cancellationToken)
+ {
+ return _libraryManager.GetShadowView(parent, viewType, sortName, cancellationToken);
+ }
+
+ public List<Tuple<BaseItem, List<BaseItem>>> GetLatestItems(LatestItemsQuery request)
+ {
+ var user = _userManager.GetUserById(request.UserId);
+
+ var libraryItems = GetItemsForLatestItems(user, request);
+
+ var list = new List<Tuple<BaseItem, List<BaseItem>>>();
+
+ foreach (var item in libraryItems)
+ {
+ // Only grab the index container for media
+ var container = item.IsFolder || !request.GroupItems ? null : item.LatestItemsIndexContainer;
+
+ if (container == null)
+ {
+ list.Add(new Tuple<BaseItem, List<BaseItem>>(null, new List<BaseItem> { item }));
+ }
+ else
+ {
+ var current = list.FirstOrDefault(i => i.Item1 != null && i.Item1.Id == container.Id);
+
+ if (current != null)
+ {
+ current.Item2.Add(item);
+ }
+ else
+ {
+ list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
+ }
+ }
+
+ if (list.Count >= request.Limit)
+ {
+ break;
+ }
+ }
+
+ return list;
+ }
+
+ private IEnumerable<BaseItem> GetItemsForLatestItems(User user, LatestItemsQuery request)
+ {
+ var parentId = request.ParentId;
+
+ var includeItemTypes = request.IncludeItemTypes;
+ var limit = request.Limit ?? 10;
+
+ var parents = new List<BaseItem>();
+
+ if (!string.IsNullOrWhiteSpace(parentId))
+ {
+ var parent = _libraryManager.GetItemById(parentId) as Folder;
+ if (parent != null)
+ {
+ parents.Add(parent);
+ }
+ }
+
+ if (parents.Count == 0)
+ {
+ parents = user.RootFolder.GetChildren(user, true)
+ .Where(i => i is Folder)
+ .Where(i => !user.Configuration.LatestItemsExcludes.Contains(i.Id.ToString("N")))
+ .ToList();
+ }
+
+ if (parents.Count == 0)
+ {
+ return new List<BaseItem>();
+ }
+
+ var excludeItemTypes = includeItemTypes.Length == 0 ? new[]
+ {
+ typeof(Person).Name,
+ typeof(Studio).Name,
+ typeof(Year).Name,
+ typeof(GameGenre).Name,
+ typeof(MusicGenre).Name,
+ typeof(Genre).Name
+
+ } : new string[] { };
+
+ return _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = includeItemTypes,
+ SortOrder = SortOrder.Descending,
+ SortBy = new[] { ItemSortBy.DateCreated },
+ IsFolder = includeItemTypes.Length == 0 ? false : (bool?)null,
+ ExcludeItemTypes = excludeItemTypes,
+ ExcludeLocationTypes = new[] { LocationType.Virtual },
+ Limit = limit * 5,
+ SourceTypes = parents.Count == 0 ? new[] { SourceType.Library } : new SourceType[] { },
+ IsPlayed = request.IsPlayed
+
+ }, parents);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs
new file mode 100644
index 000000000..4d718dbee
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs
@@ -0,0 +1,44 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class ArtistsPostScanTask
+ /// </summary>
+ public class ArtistsPostScanTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ public ArtistsPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new ArtistsValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
new file mode 100644
index 000000000..643c5970e
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
@@ -0,0 +1,84 @@
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class ArtistsValidator
+ /// </summary>
+ public class ArtistsValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public ArtistsValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetAllArtistNames();
+
+ var numComplete = 0;
+ var count = names.Count;
+
+ foreach (var name in names)
+ {
+ try
+ {
+ var item = _libraryManager.GetArtist(name);
+
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing {0}", ex, name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/GameGenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/GameGenresPostScanTask.cs
new file mode 100644
index 000000000..ee6c4461c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/GameGenresPostScanTask.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class GameGenresPostScanTask
+ /// </summary>
+ public class GameGenresPostScanTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GameGenresPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public GameGenresPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new GameGenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/GameGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GameGenresValidator.cs
new file mode 100644
index 000000000..b1820bb91
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/GameGenresValidator.cs
@@ -0,0 +1,74 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ class GameGenresValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ public GameGenresValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetGameGenreNames();
+
+ var numComplete = 0;
+ var count = names.Count;
+
+ foreach (var name in names)
+ {
+ try
+ {
+ var item = _libraryManager.GetGameGenre(name);
+
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing {0}", ex, name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs
new file mode 100644
index 000000000..be46decfb
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs
@@ -0,0 +1,42 @@
+using MediaBrowser.Controller.Library;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ public class GenresPostScanTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public GenresPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new GenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs
new file mode 100644
index 000000000..d8956f78a
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs
@@ -0,0 +1,75 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ class GenresValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ public GenresValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetGenreNames();
+
+ var numComplete = 0;
+ var count = names.Count;
+
+ foreach (var name in names)
+ {
+ try
+ {
+ var item = _libraryManager.GetGenre(name);
+
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing {0}", ex, name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs
new file mode 100644
index 000000000..cd4021548
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class MusicGenresPostScanTask
+ /// </summary>
+ public class MusicGenresPostScanTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public MusicGenresPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new MusicGenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs
new file mode 100644
index 000000000..983c881b7
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs
@@ -0,0 +1,75 @@
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ class MusicGenresValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ public MusicGenresValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetMusicGenreNames();
+
+ var numComplete = 0;
+ var count = names.Count;
+
+ foreach (var name in names)
+ {
+ try
+ {
+ var item = _libraryManager.GetMusicGenre(name);
+
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing {0}", ex, name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
new file mode 100644
index 000000000..7ebfd71c0
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -0,0 +1,115 @@
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class PeopleValidator
+ /// </summary>
+ public class PeopleValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly IServerConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PeopleValidator" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IServerConfigurationManager config, IFileSystem fileSystem)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _config = config;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Validates the people.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var innerProgress = new ActionableProgress<double>();
+
+ innerProgress.RegisterAction(pct => progress.Report(pct * .15));
+
+ var people = _libraryManager.GetPeople(new InternalPeopleQuery());
+
+ var dict = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var person in people)
+ {
+ dict[person.Name] = true;
+ }
+
+ var numComplete = 0;
+
+ _logger.Debug("Will refresh {0} people", dict.Count);
+
+ var numPeople = dict.Count;
+
+ foreach (var person in dict)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var item = _libraryManager.GetPerson(person.Key);
+
+ var options = new MetadataRefreshOptions(_fileSystem)
+ {
+ ImageRefreshMode = ImageRefreshMode.ValidationOnly,
+ MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
+ };
+
+ await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error validating IBN entry {0}", ex, person);
+ }
+
+ // Update progress
+ numComplete++;
+ double percent = numComplete;
+ percent /= numPeople;
+
+ progress.Report(100 * percent);
+ }
+
+ progress.Report(100);
+
+ _logger.Info("People validation complete");
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs
new file mode 100644
index 000000000..d23efb6d3
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ /// <summary>
+ /// Class MusicGenresPostScanTask
+ /// </summary>
+ public class StudiosPostScanTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ public StudiosPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new StudiosValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
new file mode 100644
index 000000000..6faab7bb9
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
@@ -0,0 +1,74 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ class StudiosValidator
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ private readonly IItemRepository _itemRepo;
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ public StudiosValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetStudioNames();
+
+ var numComplete = 0;
+ var count = names.Count;
+
+ foreach (var name in names)
+ {
+ try
+ {
+ var item = _libraryManager.GetStudio(name);
+
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing {0}", ex, name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+
+ progress.Report(100);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Validators/YearsPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/YearsPostScanTask.cs
new file mode 100644
index 000000000..ae43c77f0
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Validators/YearsPostScanTask.cs
@@ -0,0 +1,55 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Library.Validators
+{
+ public class YearsPostScanTask : ILibraryPostScanTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+
+ public YearsPostScanTask(ILibraryManager libraryManager, ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ }
+
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var yearNumber = 1900;
+ var maxYear = DateTime.UtcNow.Year + 3;
+ var count = maxYear - yearNumber + 1;
+ var numComplete = 0;
+
+ while (yearNumber < maxYear)
+ {
+ try
+ {
+ var year = _libraryManager.GetYear(yearNumber);
+
+ await year.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing year {0}", ex, yearNumber);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ yearNumber++;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/ChannelImageProvider.cs b/Emby.Server.Implementations/LiveTv/ChannelImageProvider.cs
new file mode 100644
index 000000000..95cefd999
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/ChannelImageProvider.cs
@@ -0,0 +1,85 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class ChannelImageProvider : IDynamicImageProvider, IHasItemChangeMonitor
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IHttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly IApplicationHost _appHost;
+
+ public ChannelImageProvider(ILiveTvManager liveTvManager, IHttpClient httpClient, ILogger logger, IApplicationHost appHost)
+ {
+ _liveTvManager = liveTvManager;
+ _httpClient = httpClient;
+ _logger = logger;
+ _appHost = appHost;
+ }
+
+ public IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new[] { ImageType.Primary };
+ }
+
+ public async Task<DynamicImageResponse> GetImage(IHasImages item, ImageType type, CancellationToken cancellationToken)
+ {
+ var liveTvItem = (LiveTvChannel)item;
+
+ var imageResponse = new DynamicImageResponse();
+
+ var service = _liveTvManager.Services.FirstOrDefault(i => string.Equals(i.Name, liveTvItem.ServiceName, StringComparison.OrdinalIgnoreCase));
+
+ if (service != null && !item.HasImage(ImageType.Primary))
+ {
+ try
+ {
+ var response = await service.GetChannelImageAsync(liveTvItem.ExternalId, cancellationToken).ConfigureAwait(false);
+
+ if (response != null)
+ {
+ imageResponse.HasImage = true;
+ imageResponse.Stream = response.Stream;
+ imageResponse.Format = response.Format;
+ }
+ }
+ catch (NotImplementedException)
+ {
+ }
+ }
+
+ return imageResponse;
+ }
+
+ public string Name
+ {
+ get { return "Live TV Service Provider"; }
+ }
+
+ public bool Supports(IHasImages item)
+ {
+ return item is LiveTvChannel;
+ }
+
+ public int Order
+ {
+ get { return 0; }
+ }
+
+ public bool HasChanged(IHasMetadata item, IDirectoryService directoryService)
+ {
+ return GetSupportedImages(item).Any(i => !item.HasImage(i));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs
new file mode 100644
index 000000000..6d527c1cf
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs
@@ -0,0 +1,106 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class DirectRecorder : IRecorder
+ {
+ private readonly ILogger _logger;
+ private readonly IHttpClient _httpClient;
+ private readonly IFileSystem _fileSystem;
+
+ public DirectRecorder(ILogger logger, IHttpClient httpClient, IFileSystem fileSystem)
+ {
+ _logger = logger;
+ _httpClient = httpClient;
+ _fileSystem = fileSystem;
+ }
+
+ public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile)
+ {
+ return targetFile;
+ }
+
+ public async Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
+ {
+ var httpRequestOptions = new HttpRequestOptions
+ {
+ Url = mediaSource.Path,
+ BufferContent = false
+ };
+
+ using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false))
+ {
+ _logger.Info("Opened recording stream from tuner provider");
+
+ using (var output = _fileSystem.GetFileStream(targetFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ onStarted();
+
+ _logger.Info("Copying recording stream to file {0}", targetFile);
+
+ // The media source if infinite so we need to handle stopping ourselves
+ var durationToken = new CancellationTokenSource(duration);
+ cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token;
+
+ await CopyUntilCancelled(response.Content, output, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ _logger.Info("Recording completed to file {0}", targetFile);
+ }
+
+ private const int BufferSize = 81920;
+ public static Task CopyUntilCancelled(Stream source, Stream target, CancellationToken cancellationToken)
+ {
+ return CopyUntilCancelled(source, target, null, cancellationToken);
+ }
+ public static async Task CopyUntilCancelled(Stream source, Stream target, Action onStarted, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var bytesRead = await CopyToAsyncInternal(source, target, BufferSize, onStarted, cancellationToken).ConfigureAwait(false);
+
+ onStarted = null;
+
+ //var position = fs.Position;
+ //_logger.Debug("Streamed {0} bytes to position {1} from file {2}", bytesRead, position, path);
+
+ if (bytesRead == 0)
+ {
+ await Task.Delay(100).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static async Task<int> CopyToAsyncInternal(Stream source, Stream destination, Int32 bufferSize, Action onStarted, CancellationToken cancellationToken)
+ {
+ byte[] buffer = new byte[bufferSize];
+ int bytesRead;
+ int totalBytesRead = 0;
+
+ while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0)
+ {
+ await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
+
+ totalBytesRead += bytesRead;
+
+ if (onStarted != null)
+ {
+ onStarted();
+ }
+ onStarted = null;
+ }
+
+ return totalBytesRead;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
new file mode 100644
index 000000000..84a255c7a
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
@@ -0,0 +1,2414 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Diagnostics;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.System;
+using MediaBrowser.Model.Threading;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable
+ {
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+ private readonly IHttpClient _httpClient;
+ private readonly IServerConfigurationManager _config;
+ private readonly IJsonSerializer _jsonSerializer;
+
+ private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider;
+ private readonly TimerManager _timerProvider;
+
+ private readonly LiveTvManager _liveTvManager;
+ private readonly IFileSystem _fileSystem;
+
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly IFileOrganizationService _organizationService;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IProcessFactory _processFactory;
+ private readonly ISystemEvents _systemEvents;
+
+ public static EmbyTV Current;
+
+ public event EventHandler DataSourceChanged;
+ public event EventHandler<RecordingStatusChangedEventArgs> RecordingStatusChanged;
+
+ private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings =
+ new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
+
+ public EmbyTV(IServerApplicationHost appHost, ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, IFileOrganizationService organizationService, IMediaEncoder mediaEncoder, ITimerFactory timerFactory, IProcessFactory processFactory, ISystemEvents systemEvents)
+ {
+ Current = this;
+
+ _appHost = appHost;
+ _logger = logger;
+ _httpClient = httpClient;
+ _config = config;
+ _fileSystem = fileSystem;
+ _libraryManager = libraryManager;
+ _libraryMonitor = libraryMonitor;
+ _providerManager = providerManager;
+ _organizationService = organizationService;
+ _mediaEncoder = mediaEncoder;
+ _processFactory = processFactory;
+ _systemEvents = systemEvents;
+ _liveTvManager = (LiveTvManager)liveTvManager;
+ _jsonSerializer = jsonSerializer;
+
+ _seriesTimerProvider = new SeriesTimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers"));
+ _timerProvider = new TimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "timers"), _logger, timerFactory);
+ _timerProvider.TimerFired += _timerProvider_TimerFired;
+
+ _config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated;
+ }
+
+ private void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+ {
+ if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
+ {
+ OnRecordingFoldersChanged();
+ }
+ }
+
+ public void Start()
+ {
+ _timerProvider.RestartTimers();
+
+ _systemEvents.Resume += _systemEvents_Resume;
+ CreateRecordingFolders();
+ }
+
+ private void _systemEvents_Resume(object sender, EventArgs e)
+ {
+ _timerProvider.RestartTimers();
+ }
+
+ private void OnRecordingFoldersChanged()
+ {
+ CreateRecordingFolders();
+ }
+
+ internal void CreateRecordingFolders()
+ {
+ try
+ {
+ CreateRecordingFoldersInternal();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error creating recording folders", ex);
+ }
+ }
+
+ internal void CreateRecordingFoldersInternal()
+ {
+ var recordingFolders = GetRecordingFolders();
+
+ var virtualFolders = _libraryManager.GetVirtualFolders()
+ .ToList();
+
+ var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
+
+ var pathsAdded = new List<string>();
+
+ foreach (var recordingFolder in recordingFolders)
+ {
+ var pathsToCreate = recordingFolder.Locations
+ .Where(i => !allExistingPaths.Contains(i, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ if (pathsToCreate.Count == 0)
+ {
+ continue;
+ }
+
+ var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo { Path = i }).ToArray();
+
+ var libraryOptions = new LibraryOptions
+ {
+ PathInfos = mediaPathInfos
+ };
+ try
+ {
+ _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error creating virtual folder", ex);
+ }
+
+ pathsAdded.AddRange(pathsToCreate);
+ }
+
+ var config = GetConfiguration();
+
+ var pathsToRemove = config.MediaLocationsCreated
+ .Except(recordingFolders.SelectMany(i => i.Locations))
+ .ToList();
+
+ if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
+ {
+ pathsAdded.InsertRange(0, config.MediaLocationsCreated);
+ config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ _config.SaveConfiguration("livetv", config);
+ }
+
+ foreach (var path in pathsToRemove)
+ {
+ RemovePathFromLibrary(path);
+ }
+ }
+
+ private void RemovePathFromLibrary(string path)
+ {
+ _logger.Debug("Removing path from library: {0}", path);
+
+ var requiresRefresh = false;
+ var virtualFolders = _libraryManager.GetVirtualFolders()
+ .ToList();
+
+ foreach (var virtualFolder in virtualFolders)
+ {
+ if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (virtualFolder.Locations.Count == 1)
+ {
+ // remove entire virtual folder
+ try
+ {
+ _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error removing virtual folder", ex);
+ }
+ }
+ else
+ {
+ try
+ {
+ _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
+ requiresRefresh = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error removing media path", ex);
+ }
+ }
+ }
+
+ if (requiresRefresh)
+ {
+ _libraryManager.ValidateMediaLibrary(new Progress<Double>(), CancellationToken.None);
+ }
+ }
+
+ public string Name
+ {
+ get { return "Emby"; }
+ }
+
+ public string DataPath
+ {
+ get { return Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); }
+ }
+
+ private string DefaultRecordingPath
+ {
+ get
+ {
+ return Path.Combine(DataPath, "recordings");
+ }
+ }
+
+ private string RecordingPath
+ {
+ get
+ {
+ var path = GetConfiguration().RecordingPath;
+
+ return string.IsNullOrWhiteSpace(path)
+ ? DefaultRecordingPath
+ : path;
+ }
+ }
+
+ public string HomePageUrl
+ {
+ get { return "http://emby.media"; }
+ }
+
+ public async Task<LiveTvServiceStatusInfo> GetStatusInfoAsync(CancellationToken cancellationToken)
+ {
+ var status = new LiveTvServiceStatusInfo();
+ var list = new List<LiveTvTunerInfo>();
+
+ foreach (var hostInstance in _liveTvManager.TunerHosts)
+ {
+ try
+ {
+ var tuners = await hostInstance.GetTunerInfos(cancellationToken).ConfigureAwait(false);
+
+ list.AddRange(tuners);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting tuners", ex);
+ }
+ }
+
+ status.Tuners = list;
+ status.Status = LiveTvServiceStatus.Ok;
+ status.Version = _appHost.ApplicationVersion.ToString();
+ status.IsVisible = false;
+ return status;
+ }
+
+ public async Task RefreshSeriesTimers(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ List<ChannelInfo> channels = null;
+
+ foreach (var timer in seriesTimers)
+ {
+ List<ProgramInfo> epgData;
+
+ if (timer.RecordAnyChannel)
+ {
+ if (channels == null)
+ {
+ channels = (await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false)).ToList();
+ }
+ var channelIds = channels.Select(i => i.Id).ToList();
+ epgData = GetEpgDataForChannels(channelIds);
+ }
+ else
+ {
+ epgData = GetEpgDataForChannel(timer.ChannelId);
+ }
+ await UpdateTimersForSeriesTimer(epgData, timer, true).ConfigureAwait(false);
+ }
+ }
+
+ public async Task RefreshTimers(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var timer in timers)
+ {
+ if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id))
+ {
+ OnTimerOutOfDate(timer);
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId))
+ {
+ continue;
+ }
+
+ var epg = GetEpgDataForChannel(timer.ChannelId);
+ var program = epg.FirstOrDefault(i => string.Equals(i.Id, timer.ProgramId, StringComparison.OrdinalIgnoreCase));
+ if (program == null)
+ {
+ OnTimerOutOfDate(timer);
+ continue;
+ }
+
+ RecordingHelper.CopyProgramInfoToTimerInfo(program, timer);
+ _timerProvider.Update(timer);
+ }
+ }
+
+ private void OnTimerOutOfDate(TimerInfo timer)
+ {
+ _timerProvider.Delete(timer);
+ }
+
+ private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ foreach (var hostInstance in _liveTvManager.TunerHosts)
+ {
+ try
+ {
+ var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
+
+ list.AddRange(channels);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channels", ex);
+ }
+ }
+
+ foreach (var provider in GetListingProviders())
+ {
+ var enabledChannels = list
+ .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId))
+ .ToList();
+
+ if (enabledChannels.Count > 0)
+ {
+ try
+ {
+ await provider.Item1.AddMetadata(provider.Item2, enabledChannels, cancellationToken).ConfigureAwait(false);
+ }
+ catch (NotSupportedException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error adding metadata", ex);
+ }
+ }
+ }
+
+ return list;
+ }
+
+ public async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ foreach (var hostInstance in _liveTvManager.TunerHosts)
+ {
+ try
+ {
+ var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
+
+ list.AddRange(channels);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channels", ex);
+ }
+ }
+
+ return list
+ .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId))
+ .ToList();
+ }
+
+ public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
+ {
+ return GetChannelsAsync(false, cancellationToken);
+ }
+
+ public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ var timers = _timerProvider
+ .GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var timer in timers)
+ {
+ CancelTimerInternal(timer.Id, true);
+ }
+
+ var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
+ if (remove != null)
+ {
+ _seriesTimerProvider.Delete(remove);
+ }
+ return Task.FromResult(true);
+ }
+
+ private void CancelTimerInternal(string timerId, bool isSeriesCancelled)
+ {
+ var timer = _timerProvider.GetTimer(timerId);
+ if (timer != null)
+ {
+ if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
+ {
+ _timerProvider.Delete(timer);
+ }
+ else
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ _timerProvider.AddOrUpdate(timer, false);
+ }
+ }
+ ActiveRecordingInfo activeRecordingInfo;
+
+ if (_activeRecordings.TryGetValue(timerId, out activeRecordingInfo))
+ {
+ activeRecordingInfo.CancellationTokenSource.Cancel();
+ }
+ }
+
+ public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ CancelTimerInternal(timerId, false);
+ return Task.FromResult(true);
+ }
+
+ public Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task<string> CreateTimer(TimerInfo timer, CancellationToken cancellationToken)
+ {
+ var existingTimer = _timerProvider.GetAll()
+ .FirstOrDefault(i => string.Equals(timer.ProgramId, i.ProgramId, StringComparison.OrdinalIgnoreCase));
+
+ if (existingTimer != null)
+ {
+ if (existingTimer.Status == RecordingStatus.Cancelled ||
+ existingTimer.Status == RecordingStatus.Completed)
+ {
+ existingTimer.Status = RecordingStatus.New;
+ _timerProvider.Update(existingTimer);
+ return Task.FromResult(existingTimer.Id);
+ }
+ else
+ {
+ throw new ArgumentException("A scheduled recording already exists for this program.");
+ }
+ }
+
+ timer.Id = Guid.NewGuid().ToString("N");
+
+ ProgramInfo programInfo = null;
+
+ if (!string.IsNullOrWhiteSpace(timer.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId);
+ }
+ if (programInfo == null)
+ {
+ _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
+ }
+
+ if (programInfo != null)
+ {
+ RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer);
+ }
+
+ _timerProvider.Add(timer);
+ return Task.FromResult(timer.Id);
+ }
+
+ public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ info.Id = Guid.NewGuid().ToString("N");
+
+ List<ProgramInfo> epgData;
+ if (info.RecordAnyChannel)
+ {
+ var channels = await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false);
+ var channelIds = channels.Select(i => i.Id).ToList();
+ epgData = GetEpgDataForChannels(channelIds);
+ }
+ else
+ {
+ epgData = GetEpgDataForChannel(info.ChannelId);
+ }
+
+ // populate info.seriesID
+ var program = epgData.FirstOrDefault(i => string.Equals(i.Id, info.ProgramId, StringComparison.OrdinalIgnoreCase));
+
+ if (program != null)
+ {
+ info.SeriesId = program.SeriesId;
+ }
+ else
+ {
+ throw new InvalidOperationException("SeriesId for program not found");
+ }
+
+ _seriesTimerProvider.Add(info);
+ await UpdateTimersForSeriesTimer(epgData, info, false).ConfigureAwait(false);
+
+ return info.Id;
+ }
+
+ public async Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (instance != null)
+ {
+ instance.ChannelId = info.ChannelId;
+ instance.Days = info.Days;
+ instance.EndDate = info.EndDate;
+ instance.IsPostPaddingRequired = info.IsPostPaddingRequired;
+ instance.IsPrePaddingRequired = info.IsPrePaddingRequired;
+ instance.PostPaddingSeconds = info.PostPaddingSeconds;
+ instance.PrePaddingSeconds = info.PrePaddingSeconds;
+ instance.Priority = info.Priority;
+ instance.RecordAnyChannel = info.RecordAnyChannel;
+ instance.RecordAnyTime = info.RecordAnyTime;
+ instance.RecordNewOnly = info.RecordNewOnly;
+ instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary;
+ instance.KeepUpTo = info.KeepUpTo;
+ instance.KeepUntil = info.KeepUntil;
+ instance.StartDate = info.StartDate;
+
+ _seriesTimerProvider.Update(instance);
+
+ List<ProgramInfo> epgData;
+ if (instance.RecordAnyChannel)
+ {
+ var channels = await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false);
+ var channelIds = channels.Select(i => i.Id).ToList();
+ epgData = GetEpgDataForChannels(channelIds);
+ }
+ else
+ {
+ epgData = GetEpgDataForChannel(instance.ChannelId);
+ }
+
+ await UpdateTimersForSeriesTimer(epgData, instance, true).ConfigureAwait(false);
+ }
+ }
+
+ public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
+ {
+ var existingTimer = _timerProvider.GetTimer(updatedTimer.Id);
+
+ if (existingTimer == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ // Only update if not currently active
+ ActiveRecordingInfo activeRecordingInfo;
+ if (!_activeRecordings.TryGetValue(updatedTimer.Id, out activeRecordingInfo))
+ {
+ existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
+ existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
+ existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
+ }
+
+ return Task.FromResult(true);
+ }
+
+ private void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer)
+ {
+ // Update the program info but retain the status
+ existingTimer.ChannelId = updatedTimer.ChannelId;
+ existingTimer.CommunityRating = updatedTimer.CommunityRating;
+ existingTimer.EndDate = updatedTimer.EndDate;
+ existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber;
+ existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle;
+ existingTimer.Genres = updatedTimer.Genres;
+ existingTimer.HomePageUrl = updatedTimer.HomePageUrl;
+ existingTimer.IsKids = updatedTimer.IsKids;
+ existingTimer.IsNews = updatedTimer.IsNews;
+ existingTimer.IsMovie = updatedTimer.IsMovie;
+ existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries;
+ existingTimer.IsRepeat = updatedTimer.IsRepeat;
+ existingTimer.IsSports = updatedTimer.IsSports;
+ existingTimer.Name = updatedTimer.Name;
+ existingTimer.OfficialRating = updatedTimer.OfficialRating;
+ existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate;
+ existingTimer.Overview = updatedTimer.Overview;
+ existingTimer.ProductionYear = updatedTimer.ProductionYear;
+ existingTimer.ProgramId = updatedTimer.ProgramId;
+ existingTimer.SeasonNumber = updatedTimer.SeasonNumber;
+ existingTimer.ShortOverview = updatedTimer.ShortOverview;
+ existingTimer.StartDate = updatedTimer.StartDate;
+ existingTimer.ShowId = updatedTimer.ShowId;
+ }
+
+ public Task<ImageStream> GetChannelImageAsync(string channelId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task<ImageStream> GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task<ImageStream> GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public async Task<IEnumerable<RecordingInfo>> GetRecordingsAsync(CancellationToken cancellationToken)
+ {
+ return _activeRecordings.Values.ToList().Select(GetRecordingInfo).ToList();
+ }
+
+ public string GetActiveRecordingPath(string id)
+ {
+ ActiveRecordingInfo info;
+
+ if (_activeRecordings.TryGetValue(id, out info))
+ {
+ return info.Path;
+ }
+ return null;
+ }
+
+ private RecordingInfo GetRecordingInfo(ActiveRecordingInfo info)
+ {
+ var timer = info.Timer;
+ var program = info.Program;
+
+ var result = new RecordingInfo
+ {
+ ChannelId = timer.ChannelId,
+ CommunityRating = timer.CommunityRating,
+ DateLastUpdated = DateTime.UtcNow,
+ EndDate = timer.EndDate,
+ EpisodeTitle = timer.EpisodeTitle,
+ Genres = timer.Genres,
+ Id = "recording" + timer.Id,
+ IsKids = timer.IsKids,
+ IsMovie = timer.IsMovie,
+ IsNews = timer.IsNews,
+ IsRepeat = timer.IsRepeat,
+ IsSeries = timer.IsProgramSeries,
+ IsSports = timer.IsSports,
+ Name = timer.Name,
+ OfficialRating = timer.OfficialRating,
+ OriginalAirDate = timer.OriginalAirDate,
+ Overview = timer.Overview,
+ ProgramId = timer.ProgramId,
+ SeriesTimerId = timer.SeriesTimerId,
+ StartDate = timer.StartDate,
+ Status = RecordingStatus.InProgress,
+ TimerId = timer.Id
+ };
+
+ if (program != null)
+ {
+ result.Audio = program.Audio;
+ result.ImagePath = program.ImagePath;
+ result.ImageUrl = program.ImageUrl;
+ result.IsHD = program.IsHD;
+ result.IsLive = program.IsLive;
+ result.IsPremiere = program.IsPremiere;
+ result.ShowId = program.ShowId;
+ }
+
+ return result;
+ }
+
+ public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
+ {
+ var excludeStatues = new List<RecordingStatus>
+ {
+ RecordingStatus.Completed
+ };
+
+ var timers = _timerProvider.GetAll()
+ .Where(i => !excludeStatues.Contains(i.Status));
+
+ return Task.FromResult(timers);
+ }
+
+ public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null)
+ {
+ var config = GetConfiguration();
+
+ var defaults = new SeriesTimerInfo()
+ {
+ PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0),
+ PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0),
+ RecordAnyChannel = false,
+ RecordAnyTime = true,
+ RecordNewOnly = true,
+
+ Days = new List<DayOfWeek>
+ {
+ DayOfWeek.Sunday,
+ DayOfWeek.Monday,
+ DayOfWeek.Tuesday,
+ DayOfWeek.Wednesday,
+ DayOfWeek.Thursday,
+ DayOfWeek.Friday,
+ DayOfWeek.Saturday
+ }
+ };
+
+ if (program != null)
+ {
+ defaults.SeriesId = program.SeriesId;
+ defaults.ProgramId = program.Id;
+ defaults.RecordNewOnly = !program.IsRepeat;
+ }
+
+ defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly;
+ defaults.KeepUntil = KeepUntil.UntilDeleted;
+
+ return Task.FromResult(defaults);
+ }
+
+ public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
+ {
+ return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerProvider.GetAll());
+ }
+
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await GetProgramsAsyncInternal(channelId, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting programs", ex);
+ return GetEpgDataForChannel(channelId).Where(i => i.StartDate <= endDateUtc && i.EndDate >= startDateUtc);
+ }
+ }
+
+ private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
+ {
+ if (info.EnableAllTuners)
+ {
+ return true;
+ }
+
+ if (string.IsNullOrWhiteSpace(tunerHostId))
+ {
+ throw new ArgumentNullException("tunerHostId");
+ }
+
+ return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private async Task<IEnumerable<ProgramInfo>> GetProgramsAsyncInternal(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false);
+ var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
+
+ foreach (var provider in GetListingProviders())
+ {
+ if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId))
+ {
+ _logger.Debug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
+ continue;
+ }
+
+ _logger.Debug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
+
+ var channelMappings = GetChannelMappings(provider.Item2);
+ var channelNumber = channel.Number;
+ string mappedChannelNumber;
+ if (channelMappings.TryGetValue(channelNumber, out mappedChannelNumber))
+ {
+ _logger.Debug("Found mapped channel on provider {0}. Tuner channel number: {1}, Mapped channel number: {2}", provider.Item1.Name, channelNumber, mappedChannelNumber);
+ channelNumber = mappedChannelNumber;
+ }
+
+ var programs = await provider.Item1.GetProgramsAsync(provider.Item2, channelNumber, channel.Name, startDateUtc, endDateUtc, cancellationToken)
+ .ConfigureAwait(false);
+
+ var list = programs.ToList();
+
+ // Replace the value that came from the provider with a normalized value
+ foreach (var program in list)
+ {
+ program.ChannelId = channelId;
+ }
+
+ if (list.Count > 0)
+ {
+ SaveEpgDataForChannel(channelId, list);
+
+ return list;
+ }
+ }
+
+ return new List<ProgramInfo>();
+ }
+
+ private Dictionary<string, string> GetChannelMappings(ListingsProviderInfo info)
+ {
+ var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var mapping in info.ChannelMappings)
+ {
+ dict[mapping.Name] = mapping.Value;
+ }
+
+ return dict;
+ }
+
+ private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders()
+ {
+ return GetConfiguration().ListingProviders
+ .Select(i =>
+ {
+ var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ return provider == null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i);
+ })
+ .Where(i => i != null)
+ .ToList();
+ }
+
+ public Task<MediaSourceInfo> GetRecordingStream(string recordingId, string streamId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ private readonly SemaphoreSlim _liveStreamsSemaphore = new SemaphoreSlim(1, 1);
+ private readonly List<LiveStream> _liveStreams = new List<LiveStream>();
+
+ public async Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ var result = await GetChannelStreamWithDirectStreamProvider(channelId, streamId, cancellationToken).ConfigureAwait(false);
+
+ return result.Item1;
+ }
+
+ public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ var result = await GetChannelStreamInternal(channelId, streamId, cancellationToken).ConfigureAwait(false);
+
+ return new Tuple<MediaSourceInfo, IDirectStreamProvider>(result.Item2, result.Item1 as IDirectStreamProvider);
+ }
+
+ private MediaSourceInfo CloneMediaSource(MediaSourceInfo mediaSource, bool enableStreamSharing)
+ {
+ var json = _jsonSerializer.SerializeToString(mediaSource);
+ mediaSource = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json);
+
+ mediaSource.Id = Guid.NewGuid().ToString("N") + "_" + mediaSource.Id;
+
+ //if (mediaSource.DateLiveStreamOpened.HasValue && enableStreamSharing)
+ //{
+ // var ticks = (DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value).Ticks - TimeSpan.FromSeconds(10).Ticks;
+ // ticks = Math.Max(0, ticks);
+ // mediaSource.Path += "?t=" + ticks.ToString(CultureInfo.InvariantCulture) + "&s=" + mediaSource.DateLiveStreamOpened.Value.Ticks.ToString(CultureInfo.InvariantCulture);
+ //}
+
+ return mediaSource;
+ }
+
+ public async Task<LiveStream> GetLiveStream(string uniqueId)
+ {
+ await _liveStreamsSemaphore.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ return _liveStreams
+ .FirstOrDefault(i => string.Equals(i.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ _liveStreamsSemaphore.Release();
+ }
+
+ }
+
+ private async Task<Tuple<LiveStream, MediaSourceInfo, ITunerHost>> GetChannelStreamInternal(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ _logger.Info("Streaming Channel " + channelId);
+
+ await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var result = _liveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase));
+
+ if (result != null && result.EnableStreamSharing)
+ {
+ var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing);
+ result.SharedStreamIds.Add(openedMediaSource.Id);
+
+ _logger.Info("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount);
+
+ return new Tuple<LiveStream, MediaSourceInfo, ITunerHost>(result, openedMediaSource, result.TunerHost);
+ }
+
+ foreach (var hostInstance in _liveTvManager.TunerHosts)
+ {
+ try
+ {
+ result = await hostInstance.GetChannelStream(channelId, streamId, cancellationToken).ConfigureAwait(false);
+
+ var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing);
+
+ result.SharedStreamIds.Add(openedMediaSource.Id);
+ _liveStreams.Add(result);
+
+ result.TunerHost = hostInstance;
+ result.OriginalStreamId = streamId;
+
+ _logger.Info("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}",
+ streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId);
+
+ return new Tuple<LiveStream, MediaSourceInfo, ITunerHost>(result, openedMediaSource, hostInstance);
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+ }
+ finally
+ {
+ _liveStreamsSemaphore.Release();
+ }
+
+ throw new Exception("Tuner not found.");
+ }
+
+ public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ foreach (var hostInstance in _liveTvManager.TunerHosts)
+ {
+ try
+ {
+ var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false);
+
+ if (sources.Count > 0)
+ {
+ return sources;
+ }
+ }
+ catch (NotImplementedException)
+ {
+
+ }
+ }
+
+ throw new NotImplementedException();
+ }
+
+ public async Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(string recordingId, CancellationToken cancellationToken)
+ {
+ ActiveRecordingInfo info;
+
+ recordingId = recordingId.Replace("recording", string.Empty);
+
+ if (_activeRecordings.TryGetValue(recordingId, out info))
+ {
+ var stream = new MediaSourceInfo
+ {
+ Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveRecordings/" + recordingId + "/stream",
+ Id = recordingId,
+ SupportsDirectPlay = false,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ RequiresOpening = false,
+ RequiresClosing = false,
+ Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
+ BufferMs = 0
+ };
+
+ var isAudio = false;
+ await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false);
+
+ return new List<MediaSourceInfo>
+ {
+ stream
+ };
+ }
+
+ throw new FileNotFoundException();
+ }
+
+ public async Task CloseLiveStream(string id, CancellationToken cancellationToken)
+ {
+ // Ignore the consumer id
+ //id = id.Substring(id.IndexOf('_') + 1);
+
+ await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var stream = _liveStreams.FirstOrDefault(i => i.SharedStreamIds.Contains(id));
+ if (stream != null)
+ {
+ stream.SharedStreamIds.Remove(id);
+
+ _logger.Info("Live stream {0} consumer count is now {1}", id, stream.ConsumerCount);
+
+ if (stream.ConsumerCount <= 0)
+ {
+ _liveStreams.Remove(stream);
+
+ _logger.Info("Closing live stream {0}", id);
+
+ await stream.Close().ConfigureAwait(false);
+ _logger.Info("Live stream {0} closed successfully", id);
+ }
+ }
+ else
+ {
+ _logger.Warn("Live stream not found: {0}, unable to close", id);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing live stream", ex);
+ }
+ finally
+ {
+ _liveStreamsSemaphore.Release();
+ }
+ }
+
+ public Task RecordLiveStream(string id, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(0);
+ }
+
+ public Task ResetTuner(string id, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(0);
+ }
+
+ async void _timerProvider_TimerFired(object sender, GenericEventArgs<TimerInfo> e)
+ {
+ var timer = e.Argument;
+
+ _logger.Info("Recording timer fired.");
+
+ try
+ {
+ var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds);
+
+ if (recordingEndDate <= DateTime.UtcNow)
+ {
+ _logger.Warn("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id);
+ OnTimerOutOfDate(timer);
+ return;
+ }
+
+ var activeRecordingInfo = new ActiveRecordingInfo
+ {
+ CancellationTokenSource = new CancellationTokenSource(),
+ Timer = timer
+ };
+
+ if (_activeRecordings.TryAdd(timer.Id, activeRecordingInfo))
+ {
+ await RecordStream(timer, recordingEndDate, activeRecordingInfo, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
+ }
+ else
+ {
+ _logger.Info("Skipping RecordStream because it's already in progress.");
+ }
+ }
+ catch (OperationCanceledException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error recording stream", ex);
+ }
+ }
+
+ private string GetRecordingPath(TimerInfo timer, out string seriesPath)
+ {
+ var recordPath = RecordingPath;
+ var config = GetConfiguration();
+ seriesPath = null;
+
+ if (timer.IsProgramSeries)
+ {
+ var customRecordingPath = config.SeriesRecordingPath;
+ var allowSubfolder = true;
+ if (!string.IsNullOrWhiteSpace(customRecordingPath))
+ {
+ allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
+ recordPath = customRecordingPath;
+ }
+
+ if (allowSubfolder && config.EnableRecordingSubfolders)
+ {
+ recordPath = Path.Combine(recordPath, "Series");
+ }
+
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+
+ // Can't use the year here in the folder name because it is the year of the episode, not the series.
+ recordPath = Path.Combine(recordPath, folderName);
+
+ seriesPath = recordPath;
+
+ if (timer.SeasonNumber.HasValue)
+ {
+ folderName = string.Format("Season {0}", timer.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture));
+ recordPath = Path.Combine(recordPath, folderName);
+ }
+ }
+ else if (timer.IsMovie)
+ {
+ var customRecordingPath = config.MovieRecordingPath;
+ var allowSubfolder = true;
+ if (!string.IsNullOrWhiteSpace(customRecordingPath))
+ {
+ allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
+ recordPath = customRecordingPath;
+ }
+
+ if (allowSubfolder && config.EnableRecordingSubfolders)
+ {
+ recordPath = Path.Combine(recordPath, "Movies");
+ }
+
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+ if (timer.ProductionYear.HasValue)
+ {
+ folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+ recordPath = Path.Combine(recordPath, folderName);
+ }
+ else if (timer.IsKids)
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordPath = Path.Combine(recordPath, "Kids");
+ }
+
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+ if (timer.ProductionYear.HasValue)
+ {
+ folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+ recordPath = Path.Combine(recordPath, folderName);
+ }
+ else if (timer.IsSports)
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordPath = Path.Combine(recordPath, "Sports");
+ }
+ recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+ }
+ else
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordPath = Path.Combine(recordPath, "Other");
+ }
+ recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+ }
+
+ var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
+
+ return Path.Combine(recordPath, recordingFileName);
+ }
+
+ private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate,
+ ActiveRecordingInfo activeRecordingInfo, CancellationToken cancellationToken)
+ {
+ if (timer == null)
+ {
+ throw new ArgumentNullException("timer");
+ }
+
+ ProgramInfo programInfo = null;
+
+ if (!string.IsNullOrWhiteSpace(timer.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId);
+ }
+ if (programInfo == null)
+ {
+ _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
+ }
+
+ if (programInfo != null)
+ {
+ RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer);
+ activeRecordingInfo.Program = programInfo;
+ }
+
+ string seriesPath = null;
+ var recordPath = GetRecordingPath(timer, out seriesPath);
+ var recordingStatus = RecordingStatus.New;
+
+ string liveStreamId = null;
+
+ OnRecordingStatusChanged();
+
+ try
+ {
+ var recorder = await GetRecorder().ConfigureAwait(false);
+
+ var allMediaSources = await GetChannelStreamMediaSources(timer.ChannelId, CancellationToken.None).ConfigureAwait(false);
+
+ var liveStreamInfo = await GetChannelStreamInternal(timer.ChannelId, allMediaSources[0].Id, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var mediaStreamInfo = liveStreamInfo.Item2;
+ liveStreamId = mediaStreamInfo.Id;
+
+ // HDHR doesn't seem to release the tuner right away after first probing with ffmpeg
+ //await Task.Delay(3000, cancellationToken).ConfigureAwait(false);
+
+ recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath);
+ recordPath = EnsureFileUnique(recordPath, timer.Id);
+
+ _libraryManager.RegisterIgnoredPath(recordPath);
+ _libraryMonitor.ReportFileSystemChangeBeginning(recordPath);
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(recordPath));
+ activeRecordingInfo.Path = recordPath;
+
+ var duration = recordingEndDate - DateTime.UtcNow;
+
+ _logger.Info("Beginning recording. Will record for {0} minutes.",
+ duration.TotalMinutes.ToString(CultureInfo.InvariantCulture));
+
+ _logger.Info("Writing file to path: " + recordPath);
+ _logger.Info("Opening recording stream from tuner provider");
+
+ Action onStarted = () =>
+ {
+ timer.Status = RecordingStatus.InProgress;
+ _timerProvider.AddOrUpdate(timer, false);
+
+ SaveRecordingMetadata(timer, recordPath, seriesPath);
+ EnforceKeepUpTo(timer);
+ };
+
+ await recorder.Record(mediaStreamInfo, recordPath, duration, onStarted, cancellationToken)
+ .ConfigureAwait(false);
+
+ recordingStatus = RecordingStatus.Completed;
+ _logger.Info("Recording completed: {0}", recordPath);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.Info("Recording stopped: {0}", recordPath);
+ recordingStatus = RecordingStatus.Completed;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error recording to {0}", ex, recordPath);
+ recordingStatus = RecordingStatus.Error;
+ }
+
+ if (!string.IsNullOrWhiteSpace(liveStreamId))
+ {
+ try
+ {
+ await CloseLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing live stream", ex);
+ }
+ }
+
+ _libraryManager.UnRegisterIgnoredPath(recordPath);
+ _libraryMonitor.ReportFileSystemChangeComplete(recordPath, true);
+
+ ActiveRecordingInfo removed;
+ _activeRecordings.TryRemove(timer.Id, out removed);
+
+ if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate)
+ {
+ const int retryIntervalSeconds = 60;
+ _logger.Info("Retrying recording in {0} seconds.", retryIntervalSeconds);
+
+ timer.Status = RecordingStatus.New;
+ timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds);
+ _timerProvider.AddOrUpdate(timer);
+ }
+ else if (_fileSystem.FileExists(recordPath))
+ {
+ timer.RecordingPath = recordPath;
+ timer.Status = RecordingStatus.Completed;
+ _timerProvider.AddOrUpdate(timer, false);
+ OnSuccessfulRecording(timer, recordPath);
+ }
+ else
+ {
+ _timerProvider.Delete(timer);
+ }
+
+ OnRecordingStatusChanged();
+ }
+
+ private void OnRecordingStatusChanged()
+ {
+ EventHelper.FireEventIfNotNull(RecordingStatusChanged, this, new RecordingStatusChangedEventArgs
+ {
+
+ }, _logger);
+ }
+
+ private async void EnforceKeepUpTo(TimerInfo timer)
+ {
+ if (string.IsNullOrWhiteSpace(timer.SeriesTimerId))
+ {
+ return;
+ }
+
+ var seriesTimerId = timer.SeriesTimerId;
+ var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
+
+ if (seriesTimer == null || seriesTimer.KeepUpTo <= 1)
+ {
+ return;
+ }
+
+ if (_disposed)
+ {
+ return;
+ }
+
+ await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var timersToDelete = _timerProvider.GetAll()
+ .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath))
+ .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(i => i.EndDate)
+ .Where(i => _fileSystem.FileExists(i.RecordingPath))
+ .Skip(seriesTimer.KeepUpTo - 1)
+ .ToList();
+
+ await DeleteLibraryItemsForTimers(timersToDelete).ConfigureAwait(false);
+ }
+ finally
+ {
+ _recordingDeleteSemaphore.Release();
+ }
+ }
+
+ private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1);
+ private async Task DeleteLibraryItemsForTimers(List<TimerInfo> timers)
+ {
+ foreach (var timer in timers)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ await DeleteLibraryItemForTimer(timer).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting recording", ex);
+ }
+ }
+ }
+
+ private async Task DeleteLibraryItemForTimer(TimerInfo timer)
+ {
+ var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
+
+ if (libraryItem != null)
+ {
+ await _libraryManager.DeleteItem(libraryItem, new DeleteOptions
+ {
+ DeleteFileLocation = true
+ });
+ }
+ else
+ {
+ try
+ {
+ _fileSystem.DeleteFile(timer.RecordingPath);
+ }
+ catch (IOException)
+ {
+
+ }
+ }
+
+ _timerProvider.Delete(timer);
+ }
+
+ private string EnsureFileUnique(string path, string timerId)
+ {
+ var originalPath = path;
+ var index = 1;
+
+ while (FileExists(path, timerId))
+ {
+ var parent = Path.GetDirectoryName(originalPath);
+ var name = Path.GetFileNameWithoutExtension(originalPath);
+ name += "-" + index.ToString(CultureInfo.InvariantCulture);
+
+ path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath));
+ index++;
+ }
+
+ return path;
+ }
+
+ private bool FileExists(string path, string timerId)
+ {
+ if (_fileSystem.FileExists(path))
+ {
+ return true;
+ }
+
+ var hasRecordingAtPath = _activeRecordings
+ .Values
+ .ToList()
+ .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase));
+
+ if (hasRecordingAtPath)
+ {
+ return true;
+ }
+ return false;
+ }
+
+ private async Task<IRecorder> GetRecorder()
+ {
+ var config = GetConfiguration();
+
+ if (config.EnableRecordingEncoding)
+ {
+ var regInfo = await _liveTvManager.GetRegistrationInfo("embytvrecordingconversion").ConfigureAwait(false);
+
+ if (regInfo.IsValid)
+ {
+ return new EncodedRecorder(_logger, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, config, _httpClient, _processFactory);
+ }
+ }
+
+ return new DirectRecorder(_logger, _httpClient, _fileSystem);
+ }
+
+ private async void OnSuccessfulRecording(TimerInfo timer, string path)
+ {
+ //if (timer.IsProgramSeries && GetConfiguration().EnableAutoOrganize)
+ //{
+ // try
+ // {
+ // // this is to account for the library monitor holding a lock for additional time after the change is complete.
+ // // ideally this shouldn't be hard-coded
+ // await Task.Delay(30000).ConfigureAwait(false);
+
+ // var organize = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager, _libraryMonitor, _providerManager);
+
+ // var result = await organize.OrganizeEpisodeFile(path, _config.GetAutoOrganizeOptions(), false, CancellationToken.None).ConfigureAwait(false);
+
+ // if (result.Status == FileSortingStatus.Success)
+ // {
+ // return;
+ // }
+ // }
+ // catch (Exception ex)
+ // {
+ // _logger.ErrorException("Error processing new recording", ex);
+ // }
+ //}
+ PostProcessRecording(timer, path);
+ }
+
+ private void PostProcessRecording(TimerInfo timer, string path)
+ {
+ var options = GetConfiguration();
+ if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
+ {
+ return;
+ }
+
+ try
+ {
+ var process = _processFactory.Create(new ProcessOptions
+ {
+ Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments),
+ CreateNoWindow = true,
+ EnableRaisingEvents = true,
+ ErrorDialog = false,
+ FileName = options.RecordingPostProcessor,
+ IsHidden = true,
+ UseShellExecute = false
+ });
+
+ _logger.Info("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+ process.Exited += Process_Exited;
+ process.Start();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error running recording post processor", ex);
+ }
+ }
+
+ private string GetPostProcessArguments(string path, string arguments)
+ {
+ return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void Process_Exited(object sender, EventArgs e)
+ {
+ var process = (IProcess)sender;
+ try
+ {
+ _logger.Info("Recording post-processing script completed with exit code {0}", process.ExitCode);
+ }
+ catch
+ {
+
+ }
+
+ process.Dispose();
+ }
+
+ private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
+ {
+ if (!image.IsLocalFile)
+ {
+ image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
+ }
+
+ string imageSaveFilenameWithoutExtension = null;
+
+ switch (image.Type)
+ {
+ case ImageType.Primary:
+
+ if (program.IsSeries)
+ {
+ imageSaveFilenameWithoutExtension = Path.GetFileNameWithoutExtension(recordingPath) + "-thumb";
+ }
+ else
+ {
+ imageSaveFilenameWithoutExtension = "poster";
+ }
+
+ break;
+ case ImageType.Logo:
+ imageSaveFilenameWithoutExtension = "logo";
+ break;
+ case ImageType.Thumb:
+ imageSaveFilenameWithoutExtension = "landscape";
+ break;
+ case ImageType.Backdrop:
+ imageSaveFilenameWithoutExtension = "fanart";
+ break;
+ default:
+ break;
+ }
+
+ if (string.IsNullOrWhiteSpace(imageSaveFilenameWithoutExtension))
+ {
+ return;
+ }
+
+ var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension);
+
+ // preserve original image extension
+ imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path));
+
+ _fileSystem.CopyFile(image.Path, imageSavePath, true);
+ }
+
+ private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program)
+ {
+ var image = program.GetImageInfo(ImageType.Primary, 0);
+
+ if (image != null && program.IsMovie)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving recording image", ex);
+ }
+ }
+
+ if (!program.IsSeries)
+ {
+ image = program.GetImageInfo(ImageType.Backdrop, 0);
+ if (image != null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving recording image", ex);
+ }
+ }
+
+ image = program.GetImageInfo(ImageType.Thumb, 0);
+ if (image != null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving recording image", ex);
+ }
+ }
+
+ image = program.GetImageInfo(ImageType.Logo, 0);
+ if (image != null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving recording image", ex);
+ }
+ }
+ }
+ }
+
+ private async void SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath)
+ {
+ try
+ {
+ var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+ Limit = 1,
+ ExternalId = timer.ProgramId
+
+ }).FirstOrDefault() as LiveTvProgram;
+
+ // dummy this up
+ if (program == null)
+ {
+ program = new LiveTvProgram
+ {
+ Name = timer.Name,
+ HomePageUrl = timer.HomePageUrl,
+ ShortOverview = timer.ShortOverview,
+ Overview = timer.Overview,
+ Genres = timer.Genres,
+ CommunityRating = timer.CommunityRating,
+ OfficialRating = timer.OfficialRating,
+ ProductionYear = timer.ProductionYear,
+ PremiereDate = timer.OriginalAirDate,
+ IndexNumber = timer.EpisodeNumber,
+ ParentIndexNumber = timer.SeasonNumber
+ };
+ }
+
+ if (timer.IsSports)
+ {
+ AddGenre(program.Genres, "Sports");
+ }
+ if (timer.IsKids)
+ {
+ AddGenre(program.Genres, "Kids");
+ AddGenre(program.Genres, "Children");
+ }
+ if (timer.IsNews)
+ {
+ AddGenre(program.Genres, "News");
+ }
+
+ if (timer.IsProgramSeries)
+ {
+ SaveSeriesNfo(timer, seriesPath);
+ SaveVideoNfo(timer, recordingPath, program, false);
+ }
+ else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
+ {
+ SaveVideoNfo(timer, recordingPath, program, true);
+ }
+ else
+ {
+ SaveVideoNfo(timer, recordingPath, program, false);
+ }
+
+ await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving nfo", ex);
+ }
+ }
+
+ private void SaveSeriesNfo(TimerInfo timer, string seriesPath)
+ {
+ var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
+
+ if (_fileSystem.FileExists(nfoPath))
+ {
+ return;
+ }
+
+ using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ var settings = new XmlWriterSettings
+ {
+ Indent = true,
+ Encoding = Encoding.UTF8,
+ CloseOutput = false
+ };
+
+ using (XmlWriter writer = XmlWriter.Create(stream, settings))
+ {
+ writer.WriteStartDocument(true);
+ writer.WriteStartElement("tvshow");
+
+ if (!string.IsNullOrWhiteSpace(timer.Name))
+ {
+ writer.WriteElementString("title", timer.Name);
+ }
+
+ if (!string.IsNullOrEmpty(timer.OfficialRating))
+ {
+ writer.WriteElementString("mpaa", timer.OfficialRating);
+ }
+
+ foreach (var genre in timer.Genres)
+ {
+ writer.WriteElementString("genre", genre);
+ }
+
+ writer.WriteEndElement();
+ writer.WriteEndDocument();
+ }
+ }
+ }
+
+ public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
+ private void SaveVideoNfo(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
+ {
+ var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
+
+ if (_fileSystem.FileExists(nfoPath))
+ {
+ return;
+ }
+
+ using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ var settings = new XmlWriterSettings
+ {
+ Indent = true,
+ Encoding = Encoding.UTF8,
+ CloseOutput = false
+ };
+
+ var options = _config.GetNfoConfiguration();
+
+ using (XmlWriter writer = XmlWriter.Create(stream, settings))
+ {
+ writer.WriteStartDocument(true);
+
+ if (timer.IsProgramSeries)
+ {
+ writer.WriteStartElement("episodedetails");
+
+ if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
+ {
+ writer.WriteElementString("title", timer.EpisodeTitle);
+ }
+
+ if (item.PremiereDate.HasValue)
+ {
+ var formatString = options.ReleaseDateFormat;
+
+ writer.WriteElementString("aired", item.PremiereDate.Value.ToLocalTime().ToString(formatString));
+ }
+
+ if (item.IndexNumber.HasValue)
+ {
+ writer.WriteElementString("episode", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (item.ParentIndexNumber.HasValue)
+ {
+ writer.WriteElementString("season", item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture));
+ }
+ }
+ else
+ {
+ writer.WriteStartElement("movie");
+
+ if (!string.IsNullOrWhiteSpace(item.Name))
+ {
+ writer.WriteElementString("title", item.Name);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
+ {
+ writer.WriteElementString("originaltitle", item.OriginalTitle);
+ }
+
+ if (item.PremiereDate.HasValue)
+ {
+ var formatString = options.ReleaseDateFormat;
+
+ writer.WriteElementString("premiered", item.PremiereDate.Value.ToLocalTime().ToString(formatString));
+ writer.WriteElementString("releasedate", item.PremiereDate.Value.ToLocalTime().ToString(formatString));
+ }
+ }
+
+ writer.WriteElementString("dateadded", DateTime.UtcNow.ToLocalTime().ToString(DateAddedFormat));
+
+ if (item.ProductionYear.HasValue)
+ {
+ writer.WriteElementString("year", item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (!string.IsNullOrEmpty(item.OfficialRating))
+ {
+ writer.WriteElementString("mpaa", item.OfficialRating);
+ }
+
+ if (!string.IsNullOrEmpty(item.OfficialRatingDescription))
+ {
+ writer.WriteElementString("mpaadescription", item.OfficialRatingDescription);
+ }
+
+ var overview = (item.Overview ?? string.Empty)
+ .StripHtml()
+ .Replace("&quot;", "'");
+
+ writer.WriteElementString("plot", overview);
+
+ if (lockData)
+ {
+ writer.WriteElementString("lockdata", true.ToString().ToLower());
+ }
+
+ if (item.CommunityRating.HasValue)
+ {
+ writer.WriteElementString("rating", item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ foreach (var genre in item.Genres)
+ {
+ writer.WriteElementString("genre", genre);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.ShortOverview))
+ {
+ writer.WriteElementString("outline", item.ShortOverview);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.HomePageUrl))
+ {
+ writer.WriteElementString("website", item.HomePageUrl);
+ }
+
+ var people = item.Id == Guid.Empty ? new List<PersonInfo>() : _libraryManager.GetPeople(item);
+
+ var directors = people
+ .Where(i => IsPersonType(i, PersonType.Director))
+ .Select(i => i.Name)
+ .ToList();
+
+ foreach (var person in directors)
+ {
+ writer.WriteElementString("director", person);
+ }
+
+ var writers = people
+ .Where(i => IsPersonType(i, PersonType.Writer))
+ .Select(i => i.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var person in writers)
+ {
+ writer.WriteElementString("writer", person);
+ }
+
+ foreach (var person in writers)
+ {
+ writer.WriteElementString("credits", person);
+ }
+
+ var rt = item.GetProviderId(MetadataProviders.RottenTomatoes);
+
+ if (!string.IsNullOrEmpty(rt))
+ {
+ writer.WriteElementString("rottentomatoesid", rt);
+ }
+
+ var tmdbCollection = item.GetProviderId(MetadataProviders.TmdbCollection);
+
+ if (!string.IsNullOrEmpty(tmdbCollection))
+ {
+ writer.WriteElementString("collectionnumber", tmdbCollection);
+ }
+
+ var imdb = item.GetProviderId(MetadataProviders.Imdb);
+ if (!string.IsNullOrEmpty(imdb))
+ {
+ if (item is Series)
+ {
+ writer.WriteElementString("imdb_id", imdb);
+ }
+ else
+ {
+ writer.WriteElementString("imdbid", imdb);
+ }
+ }
+
+ var tvdb = item.GetProviderId(MetadataProviders.Tvdb);
+ if (!string.IsNullOrEmpty(tvdb))
+ {
+ writer.WriteElementString("tvdbid", tvdb);
+ }
+
+ var tmdb = item.GetProviderId(MetadataProviders.Tmdb);
+ if (!string.IsNullOrEmpty(tmdb))
+ {
+ writer.WriteElementString("tmdbid", tmdb);
+ }
+
+ if (item.CriticRating.HasValue)
+ {
+ writer.WriteElementString("criticrating", item.CriticRating.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (!string.IsNullOrEmpty(item.CriticRatingSummary))
+ {
+ writer.WriteElementString("criticratingsummary", item.CriticRatingSummary);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.Tagline))
+ {
+ writer.WriteElementString("tagline", item.Tagline);
+ }
+
+ foreach (var studio in item.Studios)
+ {
+ writer.WriteElementString("studio", studio);
+ }
+
+ if (item.VoteCount.HasValue)
+ {
+ writer.WriteElementString("votes", item.VoteCount.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ writer.WriteEndElement();
+ writer.WriteEndDocument();
+ }
+ }
+ }
+
+ private static bool IsPersonType(PersonInfo person, string type)
+ {
+ return string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void AddGenre(List<string> genres, string genre)
+ {
+ if (!genres.Contains(genre, StringComparer.OrdinalIgnoreCase))
+ {
+ genres.Add(genre);
+ }
+ }
+
+ private ProgramInfo GetProgramInfoFromCache(string channelId, string programId)
+ {
+ var epgData = GetEpgDataForChannel(channelId);
+ return epgData.FirstOrDefault(p => string.Equals(p.Id, programId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private ProgramInfo GetProgramInfoFromCache(string channelId, DateTime startDateUtc)
+ {
+ var epgData = GetEpgDataForChannel(channelId);
+ var startDateTicks = startDateUtc.Ticks;
+ // Find the first program that starts within 3 minutes
+ return epgData.FirstOrDefault(p => Math.Abs(startDateTicks - p.StartDate.Ticks) <= TimeSpan.FromMinutes(3).Ticks);
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer)
+ {
+ if (!seriesTimer.RecordAnyTime)
+ {
+ if (Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(5).Ticks)
+ {
+ return true;
+ }
+
+ if (!seriesTimer.Days.Contains(timer.StartDate.ToLocalTime().DayOfWeek))
+ {
+ return true;
+ }
+ }
+
+ if (seriesTimer.RecordNewOnly && timer.IsRepeat)
+ {
+ return true;
+ }
+
+ if (!seriesTimer.RecordAnyChannel && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer);
+ }
+
+ private void HandleDuplicateShowIds(List<TimerInfo> timers)
+ {
+ foreach (var timer in timers.Skip(1))
+ {
+ // TODO: Get smarter, prefer HD, etc
+
+ timer.Status = RecordingStatus.Cancelled;
+ _timerProvider.Update(timer);
+ }
+ }
+
+ private void SearchForDuplicateShowIds(List<TimerInfo> timers)
+ {
+ var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList();
+
+ foreach (var group in groups)
+ {
+ if (string.IsNullOrWhiteSpace(group.Key))
+ {
+ continue;
+ }
+
+ var groupTimers = group.ToList();
+
+ if (groupTimers.Count < 2)
+ {
+ continue;
+ }
+
+ HandleDuplicateShowIds(groupTimers);
+ }
+ }
+
+ private async Task UpdateTimersForSeriesTimer(List<ProgramInfo> epgData, SeriesTimerInfo seriesTimer, bool deleteInvalidTimers)
+ {
+ var allTimers = GetTimersForSeries(seriesTimer, epgData)
+ .ToList();
+
+ var registration = await _liveTvManager.GetRegistrationInfo("seriesrecordings").ConfigureAwait(false);
+
+ var enabledTimersForSeries = new List<TimerInfo>();
+
+ if (registration.IsValid)
+ {
+ foreach (var timer in allTimers)
+ {
+ var existingTimer = _timerProvider.GetTimer(timer.Id);
+
+ if (existingTimer == null)
+ {
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ }
+ else
+ {
+ enabledTimersForSeries.Add(timer);
+ }
+ _timerProvider.Add(timer);
+ }
+ else
+ {
+ // Only update if not currently active
+ ActiveRecordingInfo activeRecordingInfo;
+ if (!_activeRecordings.TryGetValue(timer.Id, out activeRecordingInfo))
+ {
+ UpdateExistingTimerWithNewMetadata(existingTimer, timer);
+
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ existingTimer.Status = RecordingStatus.Cancelled;
+ }
+
+ if (existingTimer.Status != RecordingStatus.Cancelled)
+ {
+ enabledTimersForSeries.Add(existingTimer);
+ }
+
+ existingTimer.KeepUntil = seriesTimer.KeepUntil;
+ existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired;
+ existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds;
+ existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds;
+ existingTimer.Priority = seriesTimer.Priority;
+
+ existingTimer.SeriesTimerId = seriesTimer.Id;
+ _timerProvider.Update(existingTimer);
+ }
+ }
+ }
+ }
+
+ SearchForDuplicateShowIds(enabledTimersForSeries);
+
+ if (deleteInvalidTimers)
+ {
+ var allTimerIds = allTimers
+ .Select(i => i.Id)
+ .ToList();
+
+ var deleteStatuses = new List<RecordingStatus>
+ {
+ RecordingStatus.New
+ };
+
+ var deletes = _timerProvider.GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase))
+ .Where(i => !allTimerIds.Contains(i.Id, StringComparer.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow)
+ .Where(i => deleteStatuses.Contains(i.Status))
+ .ToList();
+
+ foreach (var timer in deletes)
+ {
+ CancelTimerInternal(timer.Id, false);
+ }
+ }
+ }
+
+ private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer, IEnumerable<ProgramInfo> allPrograms)
+ {
+ if (seriesTimer == null)
+ {
+ throw new ArgumentNullException("seriesTimer");
+ }
+ if (allPrograms == null)
+ {
+ throw new ArgumentNullException("allPrograms");
+ }
+
+ // Exclude programs that have already ended
+ allPrograms = allPrograms.Where(i => i.EndDate > DateTime.UtcNow);
+
+ allPrograms = GetProgramsForSeries(seriesTimer, allPrograms);
+
+ return allPrograms.Select(i => RecordingHelper.CreateTimer(i, seriesTimer));
+ }
+
+ private bool IsProgramAlreadyInLibrary(TimerInfo program)
+ {
+ if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle))
+ {
+ var seriesIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ Name = program.Name
+
+ }).Select(i => i.ToString("N")).ToArray();
+
+ if (seriesIds.Length == 0)
+ {
+ return false;
+ }
+
+ if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue)
+ {
+ var result = _libraryManager.GetItemsResult(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(Episode).Name },
+ ParentIndexNumber = program.SeasonNumber.Value,
+ IndexNumber = program.EpisodeNumber.Value,
+ AncestorIds = seriesIds,
+ IsVirtualItem = false
+ });
+
+ if (result.TotalRecordCount > 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private IEnumerable<ProgramInfo> GetProgramsForSeries(SeriesTimerInfo seriesTimer, IEnumerable<ProgramInfo> allPrograms)
+ {
+ if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId))
+ {
+ _logger.Error("seriesTimer.SeriesId is null. Cannot find programs for series");
+ return new List<ProgramInfo>();
+ }
+
+ return allPrograms.Where(i => string.Equals(i.SeriesId, seriesTimer.SeriesId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private string GetChannelEpgCachePath(string channelId)
+ {
+ return Path.Combine(_config.CommonApplicationPaths.CachePath, "embytvepg", channelId + ".json");
+ }
+
+ private readonly object _epgLock = new object();
+ private void SaveEpgDataForChannel(string channelId, List<ProgramInfo> epgData)
+ {
+ var path = GetChannelEpgCachePath(channelId);
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+ lock (_epgLock)
+ {
+ _jsonSerializer.SerializeToFile(epgData, path);
+ }
+ }
+ private List<ProgramInfo> GetEpgDataForChannel(string channelId)
+ {
+ try
+ {
+ lock (_epgLock)
+ {
+ return _jsonSerializer.DeserializeFromFile<List<ProgramInfo>>(GetChannelEpgCachePath(channelId));
+ }
+ }
+ catch
+ {
+ return new List<ProgramInfo>();
+ }
+ }
+ private List<ProgramInfo> GetEpgDataForChannels(List<string> channelIds)
+ {
+ return channelIds.SelectMany(GetEpgDataForChannel).ToList();
+ }
+
+ private bool _disposed;
+ public void Dispose()
+ {
+ _disposed = true;
+ foreach (var pair in _activeRecordings.ToList())
+ {
+ pair.Value.CancellationTokenSource.Cancel();
+ }
+ }
+
+ public List<VirtualFolderInfo> GetRecordingFolders()
+ {
+ var list = new List<VirtualFolderInfo>();
+
+ var defaultFolder = RecordingPath;
+ var defaultName = "Recordings";
+
+ if (_fileSystem.DirectoryExists(defaultFolder))
+ {
+ list.Add(new VirtualFolderInfo
+ {
+ Locations = new List<string> { defaultFolder },
+ Name = defaultName
+ });
+ }
+
+ var customPath = GetConfiguration().MovieRecordingPath;
+ if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath))
+ {
+ list.Add(new VirtualFolderInfo
+ {
+ Locations = new List<string> { customPath },
+ Name = "Recorded Movies",
+ CollectionType = CollectionType.Movies
+ });
+ }
+
+ customPath = GetConfiguration().SeriesRecordingPath;
+ if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath))
+ {
+ list.Add(new VirtualFolderInfo
+ {
+ Locations = new List<string> { customPath },
+ Name = "Recorded Series",
+ CollectionType = CollectionType.TvShows
+ });
+ }
+
+ return list;
+ }
+
+ class ActiveRecordingInfo
+ {
+ public string Path { get; set; }
+ public TimerInfo Timer { get; set; }
+ public ProgramInfo Program { get; set; }
+ public CancellationTokenSource CancellationTokenSource { get; set; }
+ }
+ }
+ public static class ConfigurationExtension
+ {
+ public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager manager)
+ {
+ return manager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs
new file mode 100644
index 000000000..b339537ae
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs
@@ -0,0 +1,36 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Security;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class EmbyTVRegistration : IRequiresRegistration
+ {
+ private readonly ISecurityManager _securityManager;
+
+ public static EmbyTVRegistration Instance;
+
+ public EmbyTVRegistration(ISecurityManager securityManager)
+ {
+ _securityManager = securityManager;
+ Instance = this;
+ }
+
+ private bool? _isXmlTvEnabled;
+
+ public Task LoadRegistrationInfoAsync()
+ {
+ _isXmlTvEnabled = null;
+ return Task.FromResult(true);
+ }
+
+ public async Task<bool> EnableXmlTv()
+ {
+ if (!_isXmlTvEnabled.HasValue)
+ {
+ var info = await _securityManager.GetRegistrationStatus("xmltv").ConfigureAwait(false);
+ _isXmlTvEnabled = info.IsValid;
+ }
+ return _isXmlTvEnabled.Value;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
new file mode 100644
index 000000000..5e55b893f
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Diagnostics;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class EncodedRecorder : IRecorder
+ {
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IHttpClient _httpClient;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly LiveTvOptions _liveTvOptions;
+ private bool _hasExited;
+ private Stream _logFileStream;
+ private string _targetPath;
+ private IProcess _process;
+ private readonly IProcessFactory _processFactory;
+ private readonly IJsonSerializer _json;
+ private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>();
+
+ public EncodedRecorder(ILogger logger, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, IJsonSerializer json, LiveTvOptions liveTvOptions, IHttpClient httpClient, IProcessFactory processFactory)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _mediaEncoder = mediaEncoder;
+ _appPaths = appPaths;
+ _json = json;
+ _liveTvOptions = liveTvOptions;
+ _httpClient = httpClient;
+ _processFactory = processFactory;
+ }
+
+ private string OutputFormat
+ {
+ get
+ {
+ var format = _liveTvOptions.RecordingEncodingFormat;
+
+ if (string.Equals(format, "mkv", StringComparison.OrdinalIgnoreCase))
+ {
+ return "mkv";
+ }
+
+ return "mp4";
+ }
+ }
+
+ public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile)
+ {
+ return Path.ChangeExtension(targetFile, "." + OutputFormat);
+ }
+
+ public async Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
+ {
+ var durationToken = new CancellationTokenSource(duration);
+ cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token;
+
+ await RecordFromFile(mediaSource, mediaSource.Path, targetFile, duration, onStarted, cancellationToken).ConfigureAwait(false);
+
+ _logger.Info("Recording completed to file {0}", targetFile);
+ }
+
+ private Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
+ {
+ _targetPath = targetFile;
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(targetFile));
+
+ var process = _processFactory.Create(new ProcessOptions
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false,
+
+ // Must consume both stdout and stderr or deadlocks may occur
+ //RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true,
+
+ FileName = _mediaEncoder.EncoderPath,
+ Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile, duration),
+
+ IsHidden = true,
+ ErrorDialog = false,
+ EnableRaisingEvents = true
+ });
+
+ _process = process;
+
+ var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
+ _logger.Info(commandLineLogMessage);
+
+ var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt");
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(logFilePath));
+
+ // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
+ _logFileStream = _fileSystem.GetFileStream(logFilePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true);
+
+ var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(_json.SerializeToString(mediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
+ _logFileStream.Write(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length);
+
+ process.Exited += (sender, args) => OnFfMpegProcessExited(process, inputFile);
+
+ process.Start();
+
+ cancellationToken.Register(Stop);
+
+ // MUST read both stdout and stderr asynchronously or a deadlock may occurr
+ //process.BeginOutputReadLine();
+
+ onStarted();
+
+ // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback
+ StartStreamingLog(process.StandardError.BaseStream, _logFileStream);
+
+ _logger.Info("ffmpeg recording process started for {0}", _targetPath);
+
+ return _taskCompletionSource.Task;
+ }
+
+ private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile, TimeSpan duration)
+ {
+ string videoArgs;
+ if (EncodeVideo(mediaSource))
+ {
+ var maxBitrate = 25000000;
+ videoArgs = string.Format(
+ "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41",
+ GetOutputSizeParam(),
+ maxBitrate.ToString(CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ videoArgs = "-codec:v:0 copy";
+ }
+
+ var durationParam = " -t " + _mediaEncoder.GetTimeParameter(duration.Ticks);
+ var inputModifiers = "-fflags +genpts -async 1 -vsync -1";
+ var commandLineArgs = "-i \"{0}\"{4} -sn {2} -map_metadata -1 -threads 0 {3} -y \"{1}\"";
+
+ long startTimeTicks = 0;
+ //if (mediaSource.DateLiveStreamOpened.HasValue)
+ //{
+ // var elapsed = DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value;
+ // elapsed -= TimeSpan.FromSeconds(10);
+ // if (elapsed.TotalSeconds >= 0)
+ // {
+ // startTimeTicks = elapsed.Ticks + startTimeTicks;
+ // }
+ //}
+
+ if (mediaSource.ReadAtNativeFramerate)
+ {
+ inputModifiers += " -re";
+ }
+
+ if (startTimeTicks > 0)
+ {
+ inputModifiers = "-ss " + _mediaEncoder.GetTimeParameter(startTimeTicks) + " " + inputModifiers;
+ }
+
+ var analyzeDurationSeconds = 5;
+ var analyzeDuration = " -analyzeduration " +
+ (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture);
+ inputModifiers += analyzeDuration;
+
+ commandLineArgs = string.Format(commandLineArgs, inputTempFile, targetFile, videoArgs, GetAudioArgs(mediaSource), durationParam);
+
+ return inputModifiers + " " + commandLineArgs;
+ }
+
+ private string GetAudioArgs(MediaSourceInfo mediaSource)
+ {
+ var mediaStreams = mediaSource.MediaStreams ?? new List<MediaStream>();
+ var inputAudioCodec = mediaStreams.Where(i => i.Type == MediaStreamType.Audio).Select(i => i.Codec).FirstOrDefault() ?? string.Empty;
+
+ // do not copy aac because many players have difficulty with aac_latm
+ if (_liveTvOptions.EnableOriginalAudioWithEncodedRecordings && !string.Equals(inputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
+ {
+ return "-codec:a:0 copy";
+ }
+
+ var audioChannels = 2;
+ var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
+ if (audioStream != null)
+ {
+ audioChannels = audioStream.Channels ?? audioChannels;
+ }
+ return "-codec:a:0 aac -strict experimental -ab 320000";
+ }
+
+ private bool EncodeVideo(MediaSourceInfo mediaSource)
+ {
+ if (string.Equals(_liveTvOptions.RecordedVideoCodec, "copy", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var mediaStreams = mediaSource.MediaStreams ?? new List<MediaStream>();
+ return !mediaStreams.Any(i => i.Type == MediaStreamType.Video && string.Equals(i.Codec, "h264", StringComparison.OrdinalIgnoreCase) && !i.IsInterlaced);
+ }
+
+ protected string GetOutputSizeParam()
+ {
+ var filters = new List<string>();
+
+ filters.Add("yadif=0:-1:0");
+
+ var output = string.Empty;
+
+ if (filters.Count > 0)
+ {
+ output += string.Format(" -vf \"{0}\"", string.Join(",", filters.ToArray()));
+ }
+
+ return output;
+ }
+
+ private void Stop()
+ {
+ if (!_hasExited)
+ {
+ try
+ {
+ _logger.Info("Killing ffmpeg recording process for {0}", _targetPath);
+
+ //process.Kill();
+ _process.StandardInput.WriteLine("q");
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error killing transcoding job for {0}", ex, _targetPath);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Processes the exited.
+ /// </summary>
+ private void OnFfMpegProcessExited(IProcess process, string inputFile)
+ {
+ _hasExited = true;
+
+ DisposeLogStream();
+
+ try
+ {
+ var exitCode = process.ExitCode;
+
+ _logger.Info("FFMpeg recording exited with code {0} for {1}", exitCode, _targetPath);
+
+ if (exitCode == 0)
+ {
+ _taskCompletionSource.TrySetResult(true);
+ }
+ else
+ {
+ _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {0} failed. Exit code {1}", _targetPath, exitCode)));
+ }
+ }
+ catch
+ {
+ _logger.Error("FFMpeg recording exited with an error for {0}.", _targetPath);
+ _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {0} failed", _targetPath)));
+ }
+ }
+
+ private void DisposeLogStream()
+ {
+ if (_logFileStream != null)
+ {
+ try
+ {
+ _logFileStream.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error disposing recording log stream", ex);
+ }
+
+ _logFileStream = null;
+ }
+ }
+
+ private async void StartStreamingLog(Stream source, Stream target)
+ {
+ try
+ {
+ using (var reader = new StreamReader(source))
+ {
+ while (!reader.EndOfStream)
+ {
+ var line = await reader.ReadLineAsync().ConfigureAwait(false);
+
+ var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line);
+
+ await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
+ await target.FlushAsync().ConfigureAwait(false);
+ }
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // Don't spam the log. This doesn't seem to throw in windows, but sometimes under linux
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reading ffmpeg recording log", ex);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs
new file mode 100644
index 000000000..139cf570e
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Controller.Plugins;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class EntryPoint : IServerEntryPoint
+ {
+ public void Run()
+ {
+ EmbyTV.Current.Start();
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs
new file mode 100644
index 000000000..3b5e60c4a
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Dto;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public interface IRecorder
+ {
+ /// <summary>
+ /// Records the specified media source.
+ /// </summary>
+ /// <param name="mediaSource">The media source.</param>
+ /// <param name="targetFile">The target file.</param>
+ /// <param name="duration">The duration.</param>
+ /// <param name="onStarted">The on started.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken);
+
+ string GetOutputPath(MediaSourceInfo mediaSource, string targetFile);
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs
new file mode 100644
index 000000000..16ae26d45
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs
@@ -0,0 +1,145 @@
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class ItemDataProvider<T>
+ where T : class
+ {
+ private readonly object _fileDataLock = new object();
+ private List<T> _items;
+ private readonly IJsonSerializer _jsonSerializer;
+ protected readonly ILogger Logger;
+ private readonly string _dataPath;
+ protected readonly Func<T, T, bool> EqualityComparer;
+ private readonly IFileSystem _fileSystem;
+
+ public ItemDataProvider(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func<T, T, bool> equalityComparer)
+ {
+ Logger = logger;
+ _dataPath = dataPath;
+ EqualityComparer = equalityComparer;
+ _jsonSerializer = jsonSerializer;
+ _fileSystem = fileSystem;
+ }
+
+ public IReadOnlyList<T> GetAll()
+ {
+ lock (_fileDataLock)
+ {
+ if (_items == null)
+ {
+ Logger.Info("Loading live tv data from {0}", _dataPath);
+ _items = GetItemsFromFile(_dataPath);
+ }
+ return _items.ToList();
+ }
+ }
+
+ private List<T> GetItemsFromFile(string path)
+ {
+ var jsonFile = path + ".json";
+
+ try
+ {
+ return _jsonSerializer.DeserializeFromFile<List<T>>(jsonFile) ?? new List<T>();
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (IOException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error deserializing {0}", ex, jsonFile);
+ }
+ return new List<T>();
+ }
+
+ private void UpdateList(List<T> newList)
+ {
+ if (newList == null)
+ {
+ throw new ArgumentNullException("newList");
+ }
+
+ var file = _dataPath + ".json";
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(file));
+
+ lock (_fileDataLock)
+ {
+ _jsonSerializer.SerializeToFile(newList, file);
+ _items = newList;
+ }
+ }
+
+ public virtual void Update(T item)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ var list = GetAll().ToList();
+
+ var index = list.FindIndex(i => EqualityComparer(i, item));
+
+ if (index == -1)
+ {
+ throw new ArgumentException("item not found");
+ }
+
+ list[index] = item;
+
+ UpdateList(list);
+ }
+
+ public virtual void Add(T item)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ var list = GetAll().ToList();
+
+ if (list.Any(i => EqualityComparer(i, item)))
+ {
+ throw new ArgumentException("item already exists");
+ }
+
+ list.Add(item);
+
+ UpdateList(list);
+ }
+
+ public void AddOrUpdate(T item)
+ {
+ var list = GetAll().ToList();
+
+ if (!list.Any(i => EqualityComparer(i, item)))
+ {
+ Add(item);
+ }
+ else
+ {
+ Update(item);
+ }
+ }
+
+ public virtual void Delete(T item)
+ {
+ var list = GetAll().Where(i => !EqualityComparer(i, item)).ToList();
+
+ UpdateList(list);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs
new file mode 100644
index 000000000..84f802d76
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs
@@ -0,0 +1,115 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.LiveTv;
+using System;
+using System.Globalization;
+using MediaBrowser.Model.LiveTv;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ internal class RecordingHelper
+ {
+ public static DateTime GetStartTime(TimerInfo timer)
+ {
+ return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds);
+ }
+
+ public static TimerInfo CreateTimer(ProgramInfo parent, SeriesTimerInfo seriesTimer)
+ {
+ var timer = new TimerInfo
+ {
+ ChannelId = parent.ChannelId,
+ Id = (seriesTimer.Id + parent.Id).GetMD5().ToString("N"),
+ StartDate = parent.StartDate,
+ EndDate = parent.EndDate,
+ ProgramId = parent.Id,
+ PrePaddingSeconds = seriesTimer.PrePaddingSeconds,
+ PostPaddingSeconds = seriesTimer.PostPaddingSeconds,
+ IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired,
+ IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired,
+ KeepUntil = seriesTimer.KeepUntil,
+ Priority = seriesTimer.Priority,
+ Name = parent.Name,
+ Overview = parent.Overview,
+ SeriesId = parent.SeriesId,
+ SeriesTimerId = seriesTimer.Id,
+ ShowId = parent.ShowId
+ };
+
+ CopyProgramInfoToTimerInfo(parent, timer);
+
+ return timer;
+ }
+
+ public static void CopyProgramInfoToTimerInfo(ProgramInfo programInfo, TimerInfo timerInfo)
+ {
+ timerInfo.Name = programInfo.Name;
+ timerInfo.StartDate = programInfo.StartDate;
+ timerInfo.EndDate = programInfo.EndDate;
+ timerInfo.ChannelId = programInfo.ChannelId;
+
+ timerInfo.SeasonNumber = programInfo.SeasonNumber;
+ timerInfo.EpisodeNumber = programInfo.EpisodeNumber;
+ timerInfo.IsMovie = programInfo.IsMovie;
+ timerInfo.IsKids = programInfo.IsKids;
+ timerInfo.IsNews = programInfo.IsNews;
+ timerInfo.IsSports = programInfo.IsSports;
+ timerInfo.ProductionYear = programInfo.ProductionYear;
+ timerInfo.EpisodeTitle = programInfo.EpisodeTitle;
+ timerInfo.OriginalAirDate = programInfo.OriginalAirDate;
+ timerInfo.IsProgramSeries = programInfo.IsSeries;
+
+ timerInfo.HomePageUrl = programInfo.HomePageUrl;
+ timerInfo.CommunityRating = programInfo.CommunityRating;
+ timerInfo.Overview = programInfo.Overview;
+ timerInfo.ShortOverview = programInfo.ShortOverview;
+ timerInfo.OfficialRating = programInfo.OfficialRating;
+ timerInfo.IsRepeat = programInfo.IsRepeat;
+ timerInfo.SeriesId = programInfo.SeriesId;
+ }
+
+ public static string GetRecordingName(TimerInfo info)
+ {
+ var name = info.Name;
+
+ if (info.IsProgramSeries)
+ {
+ var addHyphen = true;
+
+ if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue)
+ {
+ name += string.Format(" S{0}E{1}", info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture));
+ addHyphen = false;
+ }
+ else if (info.OriginalAirDate.HasValue)
+ {
+ name += " " + info.OriginalAirDate.Value.ToString("yyyy-MM-dd");
+ }
+ else
+ {
+ name += " " + DateTime.Now.ToString("yyyy-MM-dd");
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.EpisodeTitle))
+ {
+ if (addHyphen)
+ {
+ name += " -";
+ }
+
+ name += " " + info.EpisodeTitle;
+ }
+ }
+
+ else if (info.IsMovie && info.ProductionYear != null)
+ {
+ name += " (" + info.ProductionYear + ")";
+ }
+ else
+ {
+ name += " " + info.StartDate.ToString("yyyy-MM-dd") + " " + info.Id;
+ }
+
+ return name;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs
new file mode 100644
index 000000000..7bf6bf1ca
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs
@@ -0,0 +1,28 @@
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
+ {
+ public SeriesTimerManager(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath)
+ : base(fileSystem, jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ }
+
+ public override void Add(SeriesTimerInfo item)
+ {
+ if (string.IsNullOrWhiteSpace(item.Id))
+ {
+ throw new ArgumentException("SeriesTimerInfo.Id cannot be null or empty.");
+ }
+
+ base.Add(item);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs
new file mode 100644
index 000000000..35868d318
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs
@@ -0,0 +1,170 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.LiveTv.EmbyTV
+{
+ public class TimerManager : ItemDataProvider<TimerInfo>
+ {
+ private readonly ConcurrentDictionary<string, ITimer> _timers = new ConcurrentDictionary<string, ITimer>(StringComparer.OrdinalIgnoreCase);
+ private readonly ILogger _logger;
+
+ public event EventHandler<GenericEventArgs<TimerInfo>> TimerFired;
+ private readonly ITimerFactory _timerFactory;
+
+ public TimerManager(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath, ILogger logger1, ITimerFactory timerFactory)
+ : base(fileSystem, jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger = logger1;
+ _timerFactory = timerFactory;
+ }
+
+ public void RestartTimers()
+ {
+ StopTimers();
+
+ foreach (var item in GetAll().ToList())
+ {
+ AddOrUpdateSystemTimer(item);
+ }
+ }
+
+ public void StopTimers()
+ {
+ foreach (var pair in _timers.ToList())
+ {
+ pair.Value.Dispose();
+ }
+
+ _timers.Clear();
+ }
+
+ public override void Delete(TimerInfo item)
+ {
+ base.Delete(item);
+ StopTimer(item);
+ }
+
+ public override void Update(TimerInfo item)
+ {
+ base.Update(item);
+ AddOrUpdateSystemTimer(item);
+ }
+
+ public void AddOrUpdate(TimerInfo item, bool resetTimer)
+ {
+ if (resetTimer)
+ {
+ AddOrUpdate(item);
+ return;
+ }
+
+ var list = GetAll().ToList();
+
+ if (!list.Any(i => EqualityComparer(i, item)))
+ {
+ base.Add(item);
+ }
+ else
+ {
+ base.Update(item);
+ }
+ }
+
+ public override void Add(TimerInfo item)
+ {
+ if (string.IsNullOrWhiteSpace(item.Id))
+ {
+ throw new ArgumentException("TimerInfo.Id cannot be null or empty.");
+ }
+
+ base.Add(item);
+ AddOrUpdateSystemTimer(item);
+ }
+
+ private bool ShouldStartTimer(TimerInfo item)
+ {
+ if (item.Status == RecordingStatus.Completed ||
+ item.Status == RecordingStatus.Cancelled)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private void AddOrUpdateSystemTimer(TimerInfo item)
+ {
+ StopTimer(item);
+
+ if (!ShouldStartTimer(item))
+ {
+ return;
+ }
+
+ var startDate = RecordingHelper.GetStartTime(item);
+ var now = DateTime.UtcNow;
+
+ if (startDate < now)
+ {
+ EventHelper.FireEventIfNotNull(TimerFired, this, new GenericEventArgs<TimerInfo> { Argument = item }, Logger);
+ return;
+ }
+
+ var dueTime = startDate - now;
+ StartTimer(item, dueTime);
+ }
+
+ private void StartTimer(TimerInfo item, TimeSpan dueTime)
+ {
+ var timer = _timerFactory.Create(TimerCallback, item.Id, dueTime, TimeSpan.Zero);
+
+ if (_timers.TryAdd(item.Id, timer))
+ {
+ _logger.Info("Creating recording timer for {0}, {1}. Timer will fire in {2} minutes", item.Id, item.Name, dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ timer.Dispose();
+ _logger.Warn("Timer already exists for item {0}", item.Id);
+ }
+ }
+
+ private void StopTimer(TimerInfo item)
+ {
+ ITimer timer;
+ if (_timers.TryRemove(item.Id, out timer))
+ {
+ timer.Dispose();
+ }
+ }
+
+ private void TimerCallback(object state)
+ {
+ var timerId = (string)state;
+
+ var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase));
+ if (timer != null)
+ {
+ EventHelper.FireEventIfNotNull(TimerFired, this, new GenericEventArgs<TimerInfo> { Argument = timer }, Logger);
+ }
+ }
+
+ public TimerInfo GetTimer(string id)
+ {
+ return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
new file mode 100644
index 000000000..04fc78c95
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
@@ -0,0 +1,1311 @@
+using System.Net;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.LiveTv.Listings
+{
+ public class SchedulesDirect : IListingsProvider
+ {
+ private readonly ILogger _logger;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IHttpClient _httpClient;
+ private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
+ private readonly IApplicationHost _appHost;
+
+ private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
+
+ private readonly Dictionary<string, Dictionary<string, ScheduleDirect.Station>> _channelPairingCache =
+ new Dictionary<string, Dictionary<string, ScheduleDirect.Station>>(StringComparer.OrdinalIgnoreCase);
+
+ public SchedulesDirect(ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost appHost)
+ {
+ _logger = logger;
+ _jsonSerializer = jsonSerializer;
+ _httpClient = httpClient;
+ _appHost = appHost;
+ }
+
+ private string UserAgent
+ {
+ get { return "Emby/" + _appHost.ApplicationVersion; }
+ }
+
+ private List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc)
+ {
+ List<string> dates = new List<string>();
+
+ var start = new List<DateTime> { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date;
+ var end = new List<DateTime> { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date;
+
+ while (start <= end)
+ {
+ dates.Add(start.ToString("yyyy-MM-dd"));
+ start = start.AddDays(1);
+ }
+
+ return dates;
+ }
+
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, string channelName, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ List<ProgramInfo> programsInfo = new List<ProgramInfo>();
+
+ var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ _logger.Warn("SchedulesDirect token is empty, returning empty program list");
+ return programsInfo;
+ }
+
+ if (string.IsNullOrWhiteSpace(info.ListingsId))
+ {
+ _logger.Warn("ListingsId is null, returning empty program list");
+ return programsInfo;
+ }
+
+ var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
+
+ ScheduleDirect.Station station = GetStation(info.ListingsId, channelNumber, channelName);
+
+ if (station == null)
+ {
+ _logger.Info("No Schedules Direct Station found for channel {0} with name {1}", channelNumber, channelName);
+ return programsInfo;
+ }
+
+ string stationID = station.stationID;
+
+ _logger.Info("Channel Station ID is: " + stationID);
+ List<ScheduleDirect.RequestScheduleForChannel> requestList =
+ new List<ScheduleDirect.RequestScheduleForChannel>()
+ {
+ new ScheduleDirect.RequestScheduleForChannel()
+ {
+ stationID = stationID,
+ date = dates
+ }
+ };
+
+ var requestString = _jsonSerializer.SerializeToString(requestList);
+ _logger.Debug("Request string for schedules is: " + requestString);
+
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/schedules",
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ // The data can be large so give it some extra time
+ TimeoutMs = 60000,
+ LogErrorResponseBody = true
+ };
+
+ httpOptions.RequestHeaders["token"] = token;
+
+ httpOptions.RequestContent = requestString;
+ using (var response = await Post(httpOptions, true, info).ConfigureAwait(false))
+ {
+ StreamReader reader = new StreamReader(response.Content);
+ string responseString = reader.ReadToEnd();
+ var dailySchedules = _jsonSerializer.DeserializeFromString<List<ScheduleDirect.Day>>(responseString);
+ _logger.Debug("Found " + dailySchedules.Count + " programs on " + channelNumber + " ScheduleDirect");
+
+ httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/programs",
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true,
+ // The data can be large so give it some extra time
+ TimeoutMs = 60000
+ };
+
+ httpOptions.RequestHeaders["token"] = token;
+
+ List<string> programsID = new List<string>();
+ programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct().ToList();
+ var requestBody = "[\"" + string.Join("\", \"", programsID) + "\"]";
+ httpOptions.RequestContent = requestBody;
+
+ using (var innerResponse = await Post(httpOptions, true, info).ConfigureAwait(false))
+ {
+ StreamReader innerReader = new StreamReader(innerResponse.Content);
+ responseString = innerReader.ReadToEnd();
+
+ var programDetails =
+ _jsonSerializer.DeserializeFromString<List<ScheduleDirect.ProgramDetails>>(
+ responseString);
+ var programDict = programDetails.ToDictionary(p => p.programID, y => y);
+
+ var images = await GetImageForPrograms(info, programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID).ToList(), cancellationToken);
+
+ var schedules = dailySchedules.SelectMany(d => d.programs);
+ foreach (ScheduleDirect.Program schedule in schedules)
+ {
+ //_logger.Debug("Proccesing Schedule for statio ID " + stationID +
+ // " which corresponds to channel " + channelNumber + " and program id " +
+ // schedule.programID + " which says it has images? " +
+ // programDict[schedule.programID].hasImageArtwork);
+
+ if (images != null)
+ {
+ var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10));
+ if (imageIndex > -1)
+ {
+ var programEntry = programDict[schedule.programID];
+
+ var data = images[imageIndex].data ?? new List<ScheduleDirect.ImageData>();
+ data = data.OrderByDescending(GetSizeOrder).ToList();
+
+ programEntry.primaryImage = GetProgramImage(ApiUrl, data, "Logo", true, 600);
+ //programEntry.thumbImage = GetProgramImage(ApiUrl, data, "Iconic", false);
+ //programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ??
+ // GetProgramImage(ApiUrl, data, "Banner-L1", false) ??
+ // GetProgramImage(ApiUrl, data, "Banner-LO", false) ??
+ // GetProgramImage(ApiUrl, data, "Banner-LOT", false);
+ }
+ }
+
+ programsInfo.Add(GetProgram(channelNumber, schedule, programDict[schedule.programID]));
+ }
+ _logger.Info("Finished with EPGData");
+ }
+ }
+
+ return programsInfo;
+ }
+
+ private int GetSizeOrder(ScheduleDirect.ImageData image)
+ {
+ if (!string.IsNullOrWhiteSpace(image.height))
+ {
+ int value;
+ if (int.TryParse(image.height, out value))
+ {
+ return value;
+ }
+ }
+
+ return 0;
+ }
+
+ private readonly object _channelCacheLock = new object();
+ private ScheduleDirect.Station GetStation(string listingsId, string channelNumber, string channelName)
+ {
+ lock (_channelCacheLock)
+ {
+ Dictionary<string, ScheduleDirect.Station> channelPair;
+ if (_channelPairingCache.TryGetValue(listingsId, out channelPair))
+ {
+ ScheduleDirect.Station station;
+
+ if (!string.IsNullOrWhiteSpace(channelNumber) && channelPair.TryGetValue(channelNumber, out station))
+ {
+ return station;
+ }
+
+ if (!string.IsNullOrWhiteSpace(channelName))
+ {
+ channelName = NormalizeName(channelName);
+
+ var result = channelPair.Values.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase));
+
+ if (result != null)
+ {
+ return result;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(channelNumber))
+ {
+ return channelPair.Values.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ return null;
+ }
+ }
+
+ private void AddToChannelPairCache(string listingsId, string channelNumber, ScheduleDirect.Station schChannel)
+ {
+ lock (_channelCacheLock)
+ {
+ Dictionary<string, ScheduleDirect.Station> cache;
+ if (_channelPairingCache.TryGetValue(listingsId, out cache))
+ {
+ cache[channelNumber] = schChannel;
+ }
+ else
+ {
+ cache = new Dictionary<string, ScheduleDirect.Station>();
+ cache[channelNumber] = schChannel;
+ _channelPairingCache[listingsId] = cache;
+ }
+ }
+ }
+
+ private void ClearPairCache(string listingsId)
+ {
+ lock (_channelCacheLock)
+ {
+ Dictionary<string, ScheduleDirect.Station> cache;
+ if (_channelPairingCache.TryGetValue(listingsId, out cache))
+ {
+ cache.Clear();
+ }
+ }
+ }
+
+ private int GetChannelPairCacheCount(string listingsId)
+ {
+ lock (_channelCacheLock)
+ {
+ Dictionary<string, ScheduleDirect.Station> cache;
+ if (_channelPairingCache.TryGetValue(listingsId, out cache))
+ {
+ return cache.Count;
+ }
+
+ return 0;
+ }
+ }
+
+ private string NormalizeName(string value)
+ {
+ return value.Replace(" ", string.Empty).Replace("-", string.Empty);
+ }
+
+ public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels,
+ CancellationToken cancellationToken)
+ {
+ var listingsId = info.ListingsId;
+ if (string.IsNullOrWhiteSpace(listingsId))
+ {
+ throw new Exception("ListingsId required");
+ }
+
+ var token = await GetToken(info, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new Exception("token required");
+ }
+
+ ClearPairCache(listingsId);
+
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/lineups/" + listingsId,
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true,
+ // The data can be large so give it some extra time
+ TimeoutMs = 60000
+ };
+
+ httpOptions.RequestHeaders["token"] = token;
+
+ using (var response = await Get(httpOptions, true, info).ConfigureAwait(false))
+ {
+ var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Channel>(response);
+ _logger.Info("Found " + root.map.Count + " channels on the lineup on ScheduleDirect");
+ _logger.Info("Mapping Stations to Channel");
+ foreach (ScheduleDirect.Map map in root.map)
+ {
+ var channelNumber = map.logicalChannelNumber;
+
+ if (string.IsNullOrWhiteSpace(channelNumber))
+ {
+ channelNumber = map.channel;
+ }
+ if (string.IsNullOrWhiteSpace(channelNumber))
+ {
+ channelNumber = map.atscMajor + "." + map.atscMinor;
+ }
+ channelNumber = channelNumber.TrimStart('0');
+
+ _logger.Debug("Found channel: " + channelNumber + " in Schedules Direct");
+
+ var schChannel = (root.stations ?? new List<ScheduleDirect.Station>()).FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase));
+ if (schChannel != null)
+ {
+ AddToChannelPairCache(listingsId, channelNumber, schChannel);
+ }
+ else
+ {
+ AddToChannelPairCache(listingsId, channelNumber, new ScheduleDirect.Station
+ {
+ stationID = map.stationID
+ });
+ }
+ }
+ _logger.Info("Added " + GetChannelPairCacheCount(listingsId) + " channels to the dictionary");
+
+ foreach (ChannelInfo channel in channels)
+ {
+ var station = GetStation(listingsId, channel.Number, channel.Name);
+
+ if (station != null)
+ {
+ if (station.logo != null)
+ {
+ channel.ImageUrl = station.logo.URL;
+ channel.HasImage = true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(station.name))
+ {
+ channel.Name = station.name;
+ }
+ }
+ else
+ {
+ _logger.Info("Schedules Direct doesnt have data for channel: " + channel.Number + " " + channel.Name);
+ }
+ }
+ }
+ }
+
+ private ProgramInfo GetProgram(string channel, ScheduleDirect.Program programInfo,
+ ScheduleDirect.ProgramDetails details)
+ {
+ //_logger.Debug("Show type is: " + (details.showType ?? "No ShowType"));
+ DateTime startAt = GetDate(programInfo.airDateTime);
+ DateTime endAt = startAt.AddSeconds(programInfo.duration);
+ ProgramAudio audioType = ProgramAudio.Stereo;
+
+ bool repeat = programInfo.@new == null;
+ string newID = programInfo.programID + "T" + startAt.Ticks + "C" + channel;
+
+ if (programInfo.audioProperties != null)
+ {
+ if (programInfo.audioProperties.Exists(item => string.Equals(item, "atmos", StringComparison.OrdinalIgnoreCase)))
+ {
+ audioType = ProgramAudio.Atmos;
+ }
+ else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd 5.1", StringComparison.OrdinalIgnoreCase)))
+ {
+ audioType = ProgramAudio.DolbyDigital;
+ }
+ else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase)))
+ {
+ audioType = ProgramAudio.DolbyDigital;
+ }
+ else if (programInfo.audioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase)))
+ {
+ audioType = ProgramAudio.Stereo;
+ }
+ else
+ {
+ audioType = ProgramAudio.Mono;
+ }
+ }
+
+ string episodeTitle = null;
+ if (details.episodeTitle150 != null)
+ {
+ episodeTitle = details.episodeTitle150;
+ }
+
+ var showType = details.showType ?? string.Empty;
+
+ var info = new ProgramInfo
+ {
+ ChannelId = channel,
+ Id = newID,
+ StartDate = startAt,
+ EndDate = endAt,
+ Name = details.titles[0].title120 ?? "Unkown",
+ OfficialRating = null,
+ CommunityRating = null,
+ EpisodeTitle = episodeTitle,
+ Audio = audioType,
+ IsRepeat = repeat,
+ IsSeries = showType.IndexOf("series", StringComparison.OrdinalIgnoreCase) != -1,
+ ImageUrl = details.primaryImage,
+ IsKids = string.Equals(details.audience, "children", StringComparison.OrdinalIgnoreCase),
+ IsSports = showType.IndexOf("sports", StringComparison.OrdinalIgnoreCase) != -1,
+ IsMovie = showType.IndexOf("movie", StringComparison.OrdinalIgnoreCase) != -1 || showType.IndexOf("film", StringComparison.OrdinalIgnoreCase) != -1,
+ ShowId = programInfo.programID,
+ Etag = programInfo.md5
+ };
+
+ if (programInfo.videoProperties != null)
+ {
+ info.IsHD = programInfo.videoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase);
+ info.Is3D = programInfo.videoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (details.contentRating != null && details.contentRating.Count > 0)
+ {
+ info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-");
+
+ var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
+ if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase))
+ {
+ info.OfficialRating = null;
+ }
+ }
+
+ if (details.descriptions != null)
+ {
+ if (details.descriptions.description1000 != null)
+ {
+ info.Overview = details.descriptions.description1000[0].description;
+ }
+ else if (details.descriptions.description100 != null)
+ {
+ info.ShortOverview = details.descriptions.description100[0].description;
+ }
+ }
+
+ if (info.IsSeries)
+ {
+ info.SeriesId = programInfo.programID.Substring(0, 10);
+
+ if (details.metadata != null)
+ {
+ var gracenote = details.metadata.Find(x => x.Gracenote != null).Gracenote;
+ info.SeasonNumber = gracenote.season;
+ info.EpisodeNumber = gracenote.episode;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(details.originalAirDate) && (!info.IsSeries || info.IsRepeat))
+ {
+ info.OriginalAirDate = DateTime.Parse(details.originalAirDate);
+ info.ProductionYear = info.OriginalAirDate.Value.Year;
+ }
+
+ if (details.genres != null)
+ {
+ info.Genres = details.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList();
+ info.IsNews = details.genres.Contains("news", StringComparer.OrdinalIgnoreCase);
+
+ if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase))
+ {
+ info.IsKids = true;
+ }
+ }
+
+ return info;
+ }
+
+ private DateTime GetDate(string value)
+ {
+ var date = DateTime.ParseExact(value, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture);
+
+ if (date.Kind != DateTimeKind.Utc)
+ {
+ date = DateTime.SpecifyKind(date, DateTimeKind.Utc);
+ }
+ return date;
+ }
+
+ private string GetProgramImage(string apiUrl, List<ScheduleDirect.ImageData> images, string category, bool returnDefaultImage, int desiredWidth)
+ {
+ string url = null;
+
+ var matches = images
+ .Where(i => string.Equals(i.category, category, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (matches.Count == 0)
+ {
+ if (!returnDefaultImage)
+ {
+ return null;
+ }
+ matches = images;
+ }
+
+ var match = matches.FirstOrDefault(i =>
+ {
+ if (!string.IsNullOrWhiteSpace(i.width))
+ {
+ int value;
+ if (int.TryParse(i.width, out value))
+ {
+ return value <= desiredWidth;
+ }
+ }
+
+ return false;
+ });
+
+ if (match == null)
+ {
+ // Get the second lowest quality image, when possible
+ if (matches.Count > 1)
+ {
+ match = matches[matches.Count - 2];
+ }
+ else
+ {
+ match = matches.FirstOrDefault();
+ }
+ }
+
+ if (match == null)
+ {
+ return null;
+ }
+
+ var uri = match.uri;
+
+ if (!string.IsNullOrWhiteSpace(uri))
+ {
+ if (uri.IndexOf("http", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ url = uri;
+ }
+ else
+ {
+ url = apiUrl + "/image/" + uri;
+ }
+ }
+ //_logger.Debug("URL for image is : " + url);
+ return url;
+ }
+
+ private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(
+ ListingsProviderInfo info,
+ List<string> programIds,
+ CancellationToken cancellationToken)
+ {
+ var imageIdString = "[";
+
+ foreach (var i in programIds)
+ {
+ if (!imageIdString.Contains(i.Substring(0, 10)))
+ {
+ imageIdString += "\"" + i.Substring(0, 10) + "\",";
+ }
+ }
+
+ imageIdString = imageIdString.TrimEnd(',') + "]";
+
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/metadata/programs",
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ RequestContent = imageIdString,
+ LogErrorResponseBody = true,
+ // The data can be large so give it some extra time
+ TimeoutMs = 60000
+ };
+ List<ScheduleDirect.ShowImages> images;
+ using (var innerResponse2 = await Post(httpOptions, true, info).ConfigureAwait(false))
+ {
+ images = _jsonSerializer.DeserializeFromStream<List<ScheduleDirect.ShowImages>>(
+ innerResponse2.Content);
+ }
+
+ return images;
+ }
+
+ public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
+ {
+ var token = await GetToken(info, cancellationToken);
+
+ var lineups = new List<NameIdPair>();
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return lineups;
+ }
+
+ var options = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/headends?country=" + country + "&postalcode=" + location,
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true
+ };
+
+ options.RequestHeaders["token"] = token;
+
+ try
+ {
+ using (Stream responce = await Get(options, false, info).ConfigureAwait(false))
+ {
+ var root = _jsonSerializer.DeserializeFromStream<List<ScheduleDirect.Headends>>(responce);
+
+ if (root != null)
+ {
+ foreach (ScheduleDirect.Headends headend in root)
+ {
+ foreach (ScheduleDirect.Lineup lineup in headend.lineups)
+ {
+ lineups.Add(new NameIdPair
+ {
+ Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name,
+ Id = lineup.uri.Substring(18)
+ });
+ }
+ }
+ }
+ else
+ {
+ _logger.Info("No lineups available");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error getting headends", ex);
+ }
+
+ return lineups;
+ }
+
+ private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
+ private DateTime _lastErrorResponse;
+ private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ var username = info.Username;
+
+ // Reset the token if there's no username
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ return null;
+ }
+
+ var password = info.Password;
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ return null;
+ }
+
+ // Avoid hammering SD
+ if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
+ {
+ return null;
+ }
+
+ NameValuePair savedToken = null;
+ if (!_tokens.TryGetValue(username, out savedToken))
+ {
+ savedToken = new NameValuePair();
+ _tokens.TryAdd(username, savedToken);
+ }
+
+ if (!string.IsNullOrWhiteSpace(savedToken.Name) && !string.IsNullOrWhiteSpace(savedToken.Value))
+ {
+ long ticks;
+ if (long.TryParse(savedToken.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out ticks))
+ {
+ // If it's under 24 hours old we can still use it
+ if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
+ {
+ return savedToken.Name;
+ }
+ }
+ }
+
+ await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
+ savedToken.Name = result;
+ savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
+ return result;
+ }
+ catch (HttpException ex)
+ {
+ if (ex.StatusCode.HasValue)
+ {
+ if ((int)ex.StatusCode.Value == 400)
+ {
+ _tokens.Clear();
+ _lastErrorResponse = DateTime.UtcNow;
+ }
+ }
+ throw;
+ }
+ finally
+ {
+ _tokenSemaphore.Release();
+ }
+ }
+
+ private async Task<HttpResponseInfo> Post(HttpRequestOptions options,
+ bool enableRetry,
+ ListingsProviderInfo providerInfo)
+ {
+ try
+ {
+ return await _httpClient.Post(options).ConfigureAwait(false);
+ }
+ catch (HttpException ex)
+ {
+ _tokens.Clear();
+
+ if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500)
+ {
+ enableRetry = false;
+ }
+
+ if (!enableRetry)
+ {
+ throw;
+ }
+ }
+
+ var newToken = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false);
+ options.RequestHeaders["token"] = newToken;
+ return await Post(options, false, providerInfo).ConfigureAwait(false);
+ }
+
+ private async Task<Stream> Get(HttpRequestOptions options,
+ bool enableRetry,
+ ListingsProviderInfo providerInfo)
+ {
+ try
+ {
+ return await _httpClient.Get(options).ConfigureAwait(false);
+ }
+ catch (HttpException ex)
+ {
+ _tokens.Clear();
+
+ if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500)
+ {
+ enableRetry = false;
+ }
+
+ if (!enableRetry)
+ {
+ throw;
+ }
+ }
+
+ var newToken = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false);
+ options.RequestHeaders["token"] = newToken;
+ return await Get(options, false, providerInfo).ConfigureAwait(false);
+ }
+
+ private async Task<string> GetTokenInternal(string username, string password,
+ CancellationToken cancellationToken)
+ {
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/token",
+ UserAgent = UserAgent,
+ RequestContent = "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}",
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true
+ };
+ //_logger.Info("Obtaining token from Schedules Direct from addres: " + httpOptions.Url + " with body " +
+ // httpOptions.RequestContent);
+
+ using (var responce = await Post(httpOptions, false, null).ConfigureAwait(false))
+ {
+ var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Token>(responce.Content);
+ if (root.message == "OK")
+ {
+ _logger.Info("Authenticated with Schedules Direct token: " + root.token);
+ return root.token;
+ }
+
+ throw new Exception("Could not authenticate with Schedules Direct Error: " + root.message);
+ }
+ }
+
+ private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ var token = await GetToken(info, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("Authentication required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(info.ListingsId))
+ {
+ throw new ArgumentException("Listings Id required");
+ }
+
+ _logger.Info("Adding new LineUp ");
+
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/lineups/" + info.ListingsId,
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true,
+ BufferContent = false
+ };
+
+ httpOptions.RequestHeaders["token"] = token;
+
+ using (var response = await _httpClient.SendAsync(httpOptions, "PUT"))
+ {
+ }
+ }
+
+ public string Name
+ {
+ get { return "Schedules Direct"; }
+ }
+
+ public static string TypeName = "SchedulesDirect";
+ public string Type
+ {
+ get { return TypeName; }
+ }
+
+ private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(info.ListingsId))
+ {
+ throw new ArgumentException("Listings Id required");
+ }
+
+ var token = await GetToken(info, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new Exception("token required");
+ }
+
+ _logger.Info("Headends on account ");
+
+ var options = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/lineups",
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true
+ };
+
+ options.RequestHeaders["token"] = token;
+
+ try
+ {
+ using (var response = await Get(options, false, null).ConfigureAwait(false))
+ {
+ var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Lineups>(response);
+
+ return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ catch (HttpException ex)
+ {
+ // Apparently we're supposed to swallow this
+ if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
+ {
+ return false;
+ }
+
+ throw;
+ }
+ }
+
+ public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ if (validateLogin)
+ {
+ if (string.IsNullOrWhiteSpace(info.Username))
+ {
+ throw new ArgumentException("Username is required");
+ }
+ if (string.IsNullOrWhiteSpace(info.Password))
+ {
+ throw new ArgumentException("Password is required");
+ }
+ }
+ if (validateListings)
+ {
+ if (string.IsNullOrWhiteSpace(info.ListingsId))
+ {
+ throw new ArgumentException("Listings Id required");
+ }
+
+ var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
+
+ if (!hasLineup)
+ {
+ await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
+ {
+ return GetHeadends(info, country, location, CancellationToken.None);
+ }
+
+ public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ var listingsId = info.ListingsId;
+ if (string.IsNullOrWhiteSpace(listingsId))
+ {
+ throw new Exception("ListingsId required");
+ }
+
+ await AddMetadata(info, new List<ChannelInfo>(), cancellationToken).ConfigureAwait(false);
+
+ var token = await GetToken(info, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new Exception("token required");
+ }
+
+ var httpOptions = new HttpRequestOptions()
+ {
+ Url = ApiUrl + "/lineups/" + listingsId,
+ UserAgent = UserAgent,
+ CancellationToken = cancellationToken,
+ LogErrorResponseBody = true,
+ // The data can be large so give it some extra time
+ TimeoutMs = 60000
+ };
+
+ httpOptions.RequestHeaders["token"] = token;
+
+ var list = new List<ChannelInfo>();
+
+ using (var response = await Get(httpOptions, true, info).ConfigureAwait(false))
+ {
+ var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Channel>(response);
+ _logger.Info("Found " + root.map.Count + " channels on the lineup on ScheduleDirect");
+ _logger.Info("Mapping Stations to Channel");
+ foreach (ScheduleDirect.Map map in root.map)
+ {
+ var channelNumber = map.logicalChannelNumber;
+
+ if (string.IsNullOrWhiteSpace(channelNumber))
+ {
+ channelNumber = map.channel;
+ }
+ if (string.IsNullOrWhiteSpace(channelNumber))
+ {
+ channelNumber = map.atscMajor + "." + map.atscMinor;
+ }
+ channelNumber = channelNumber.TrimStart('0');
+
+ var name = channelNumber;
+ var station = GetStation(listingsId, channelNumber, null);
+
+ if (station != null && !string.IsNullOrWhiteSpace(station.name))
+ {
+ name = station.name;
+ }
+
+ list.Add(new ChannelInfo
+ {
+ Number = channelNumber,
+ Name = name
+ });
+ }
+ }
+
+ return list;
+ }
+
+ public class ScheduleDirect
+ {
+ public class Token
+ {
+ public int code { get; set; }
+ public string message { get; set; }
+ public string serverID { get; set; }
+ public string token { get; set; }
+ }
+ public class Lineup
+ {
+ public string lineup { get; set; }
+ public string name { get; set; }
+ public string transport { get; set; }
+ public string location { get; set; }
+ public string uri { get; set; }
+ }
+
+ public class Lineups
+ {
+ public int code { get; set; }
+ public string serverID { get; set; }
+ public string datetime { get; set; }
+ public List<Lineup> lineups { get; set; }
+ }
+
+
+ public class Headends
+ {
+ public string headend { get; set; }
+ public string transport { get; set; }
+ public string location { get; set; }
+ public List<Lineup> lineups { get; set; }
+ }
+
+
+
+ public class Map
+ {
+ public string stationID { get; set; }
+ public string channel { get; set; }
+ public string logicalChannelNumber { get; set; }
+ public int uhfVhf { get; set; }
+ public int atscMajor { get; set; }
+ public int atscMinor { get; set; }
+ }
+
+ public class Broadcaster
+ {
+ public string city { get; set; }
+ public string state { get; set; }
+ public string postalcode { get; set; }
+ public string country { get; set; }
+ }
+
+ public class Logo
+ {
+ public string URL { get; set; }
+ public int height { get; set; }
+ public int width { get; set; }
+ public string md5 { get; set; }
+ }
+
+ public class Station
+ {
+ public string stationID { get; set; }
+ public string name { get; set; }
+ public string callsign { get; set; }
+ public List<string> broadcastLanguage { get; set; }
+ public List<string> descriptionLanguage { get; set; }
+ public Broadcaster broadcaster { get; set; }
+ public string affiliate { get; set; }
+ public Logo logo { get; set; }
+ public bool? isCommercialFree { get; set; }
+ }
+
+ public class Metadata
+ {
+ public string lineup { get; set; }
+ public string modified { get; set; }
+ public string transport { get; set; }
+ }
+
+ public class Channel
+ {
+ public List<Map> map { get; set; }
+ public List<Station> stations { get; set; }
+ public Metadata metadata { get; set; }
+ }
+
+ public class RequestScheduleForChannel
+ {
+ public string stationID { get; set; }
+ public List<string> date { get; set; }
+ }
+
+
+
+
+ public class Rating
+ {
+ public string body { get; set; }
+ public string code { get; set; }
+ }
+
+ public class Multipart
+ {
+ public int partNumber { get; set; }
+ public int totalParts { get; set; }
+ }
+
+ public class Program
+ {
+ public string programID { get; set; }
+ public string airDateTime { get; set; }
+ public int duration { get; set; }
+ public string md5 { get; set; }
+ public List<string> audioProperties { get; set; }
+ public List<string> videoProperties { get; set; }
+ public List<Rating> ratings { get; set; }
+ public bool? @new { get; set; }
+ public Multipart multipart { get; set; }
+ }
+
+
+
+ public class MetadataSchedule
+ {
+ public string modified { get; set; }
+ public string md5 { get; set; }
+ public string startDate { get; set; }
+ public string endDate { get; set; }
+ public int days { get; set; }
+ }
+
+ public class Day
+ {
+ public string stationID { get; set; }
+ public List<Program> programs { get; set; }
+ public MetadataSchedule metadata { get; set; }
+
+ public Day()
+ {
+ programs = new List<Program>();
+ }
+ }
+
+ //
+ public class Title
+ {
+ public string title120 { get; set; }
+ }
+
+ public class EventDetails
+ {
+ public string subType { get; set; }
+ }
+
+ public class Description100
+ {
+ public string descriptionLanguage { get; set; }
+ public string description { get; set; }
+ }
+
+ public class Description1000
+ {
+ public string descriptionLanguage { get; set; }
+ public string description { get; set; }
+ }
+
+ public class DescriptionsProgram
+ {
+ public List<Description100> description100 { get; set; }
+ public List<Description1000> description1000 { get; set; }
+ }
+
+ public class Gracenote
+ {
+ public int season { get; set; }
+ public int episode { get; set; }
+ }
+
+ public class MetadataPrograms
+ {
+ public Gracenote Gracenote { get; set; }
+ }
+
+ public class ContentRating
+ {
+ public string body { get; set; }
+ public string code { get; set; }
+ }
+
+ public class Cast
+ {
+ public string billingOrder { get; set; }
+ public string role { get; set; }
+ public string nameId { get; set; }
+ public string personId { get; set; }
+ public string name { get; set; }
+ public string characterName { get; set; }
+ }
+
+ public class Crew
+ {
+ public string billingOrder { get; set; }
+ public string role { get; set; }
+ public string nameId { get; set; }
+ public string personId { get; set; }
+ public string name { get; set; }
+ }
+
+ public class QualityRating
+ {
+ public string ratingsBody { get; set; }
+ public string rating { get; set; }
+ public string minRating { get; set; }
+ public string maxRating { get; set; }
+ public string increment { get; set; }
+ }
+
+ public class Movie
+ {
+ public string year { get; set; }
+ public int duration { get; set; }
+ public List<QualityRating> qualityRating { get; set; }
+ }
+
+ public class Recommendation
+ {
+ public string programID { get; set; }
+ public string title120 { get; set; }
+ }
+
+ public class ProgramDetails
+ {
+ public string audience { get; set; }
+ public string programID { get; set; }
+ public List<Title> titles { get; set; }
+ public EventDetails eventDetails { get; set; }
+ public DescriptionsProgram descriptions { get; set; }
+ public string originalAirDate { get; set; }
+ public List<string> genres { get; set; }
+ public string episodeTitle150 { get; set; }
+ public List<MetadataPrograms> metadata { get; set; }
+ public List<ContentRating> contentRating { get; set; }
+ public List<Cast> cast { get; set; }
+ public List<Crew> crew { get; set; }
+ public string showType { get; set; }
+ public bool hasImageArtwork { get; set; }
+ public string primaryImage { get; set; }
+ public string thumbImage { get; set; }
+ public string bannerImage { get; set; }
+ public string imageID { get; set; }
+ public string md5 { get; set; }
+ public List<string> contentAdvisory { get; set; }
+ public Movie movie { get; set; }
+ public List<Recommendation> recommendations { get; set; }
+ }
+
+ public class Caption
+ {
+ public string content { get; set; }
+ public string lang { get; set; }
+ }
+
+ public class ImageData
+ {
+ public string width { get; set; }
+ public string height { get; set; }
+ public string uri { get; set; }
+ public string size { get; set; }
+ public string aspect { get; set; }
+ public string category { get; set; }
+ public string text { get; set; }
+ public string primary { get; set; }
+ public string tier { get; set; }
+ public Caption caption { get; set; }
+ }
+
+ public class ShowImages
+ {
+ public string programID { get; set; }
+ public List<ImageData> data { get; set; }
+ }
+
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
new file mode 100644
index 000000000..57307aa73
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
@@ -0,0 +1,241 @@
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.XmlTv.Classes;
+using Emby.XmlTv.Entities;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv.Listings
+{
+ public class XmlTvListingsProvider : IListingsProvider
+ {
+ private readonly IServerConfigurationManager _config;
+ private readonly IHttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+
+ public XmlTvListingsProvider(IServerConfigurationManager config, IHttpClient httpClient, ILogger logger, IFileSystem fileSystem)
+ {
+ _config = config;
+ _httpClient = httpClient;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ public string Name
+ {
+ get { return "XmlTV"; }
+ }
+
+ public string Type
+ {
+ get { return "xmltv"; }
+ }
+
+ private string GetLanguage()
+ {
+ return _config.Configuration.PreferredMetadataLanguage;
+ }
+
+ private async Task<string> GetXml(string path, CancellationToken cancellationToken)
+ {
+ _logger.Info("xmltv path: {0}", path);
+
+ if (!path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return path;
+ }
+
+ var cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + ".xml";
+ var cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename);
+ if (_fileSystem.FileExists(cacheFile))
+ {
+ return cacheFile;
+ }
+
+ _logger.Info("Downloading xmltv listings from {0}", path);
+
+ var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions
+ {
+ CancellationToken = cancellationToken,
+ Url = path,
+ Progress = new Progress<Double>(),
+ DecompressionMethod = CompressionMethod.Gzip,
+
+ // It's going to come back gzipped regardless of this value
+ // So we need to make sure the decompression method is set to gzip
+ EnableHttpCompression = true,
+
+ UserAgent = "Emby/3.0"
+
+ }).ConfigureAwait(false);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(cacheFile));
+
+ using (var stream = _fileSystem.OpenRead(tempFile))
+ {
+ using (var reader = new StreamReader(stream, Encoding.UTF8))
+ {
+ using (var fileStream = _fileSystem.GetFileStream(cacheFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ using (var writer = new StreamWriter(fileStream))
+ {
+ while (!reader.EndOfStream)
+ {
+ writer.WriteLine(reader.ReadLine());
+ }
+ }
+ }
+ }
+ }
+
+ _logger.Debug("Returning xmltv path {0}", cacheFile);
+ return cacheFile;
+ }
+
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, string channelName, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ if (!await EmbyTV.EmbyTVRegistration.Instance.EnableXmlTv().ConfigureAwait(false))
+ {
+ var length = endDateUtc - startDateUtc;
+ if (length.TotalDays > 1)
+ {
+ endDateUtc = startDateUtc.AddDays(1);
+ }
+ }
+
+ var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false);
+ var reader = new XmlTvReader(path, GetLanguage());
+
+ var results = reader.GetProgrammes(channelNumber, startDateUtc, endDateUtc, cancellationToken);
+ return results.Select(p => GetProgramInfo(p, info));
+ }
+
+ private ProgramInfo GetProgramInfo(XmlTvProgram p, ListingsProviderInfo info)
+ {
+ var episodeTitle = p.Episode == null ? null : p.Episode.Title;
+
+ var programInfo = new ProgramInfo
+ {
+ ChannelId = p.ChannelId,
+ EndDate = GetDate(p.EndDate),
+ EpisodeNumber = p.Episode == null ? null : p.Episode.Episode,
+ EpisodeTitle = episodeTitle,
+ Genres = p.Categories,
+ Id = String.Format("{0}_{1:O}", p.ChannelId, p.StartDate), // Construct an id from the channel and start date,
+ StartDate = GetDate(p.StartDate),
+ Name = p.Title,
+ Overview = p.Description,
+ ShortOverview = p.Description,
+ ProductionYear = !p.CopyrightDate.HasValue ? (int?)null : p.CopyrightDate.Value.Year,
+ SeasonNumber = p.Episode == null ? null : p.Episode.Series,
+ IsSeries = p.Episode != null,
+ IsRepeat = p.IsPreviouslyShown && !p.IsNew,
+ IsPremiere = p.Premiere != null,
+ IsKids = p.Categories.Any(c => info.KidsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)),
+ IsMovie = p.Categories.Any(c => info.MovieCategories.Contains(c, StringComparer.OrdinalIgnoreCase)),
+ IsNews = p.Categories.Any(c => info.NewsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)),
+ IsSports = p.Categories.Any(c => info.SportsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)),
+ ImageUrl = p.Icon != null && !String.IsNullOrEmpty(p.Icon.Source) ? p.Icon.Source : null,
+ HasImage = p.Icon != null && !String.IsNullOrEmpty(p.Icon.Source),
+ OfficialRating = p.Rating != null && !String.IsNullOrEmpty(p.Rating.Value) ? p.Rating.Value : null,
+ CommunityRating = p.StarRating.HasValue ? p.StarRating.Value : (float?)null,
+ SeriesId = p.Episode != null ? p.Title.GetMD5().ToString("N") : null,
+ ShowId = ((p.Title ?? string.Empty) + (episodeTitle ?? string.Empty)).GetMD5().ToString("N")
+ };
+
+ if (programInfo.IsMovie)
+ {
+ programInfo.IsSeries = false;
+ programInfo.EpisodeNumber = null;
+ programInfo.EpisodeTitle = null;
+ }
+
+ return programInfo;
+ }
+
+ private DateTime GetDate(DateTime date)
+ {
+ if (date.Kind != DateTimeKind.Utc)
+ {
+ date = DateTime.SpecifyKind(date, DateTimeKind.Utc);
+ }
+ return date;
+ }
+
+ public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken)
+ {
+ // Add the channel image url
+ var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false);
+ var reader = new XmlTvReader(path, GetLanguage());
+ var results = reader.GetChannels().ToList();
+
+ if (channels != null)
+ {
+ foreach (var c in channels)
+ {
+ var channelNumber = info.GetMappedChannel(c.Number);
+ var match = results.FirstOrDefault(r => string.Equals(r.Id, channelNumber, StringComparison.OrdinalIgnoreCase));
+
+ if (match != null && match.Icon != null && !String.IsNullOrEmpty(match.Icon.Source))
+ {
+ c.ImageUrl = match.Icon.Source;
+ }
+ }
+ }
+ }
+
+ public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ // Assume all urls are valid. check files for existence
+ if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !_fileSystem.FileExists(info.Path))
+ {
+ throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
+ }
+
+ return Task.FromResult(true);
+ }
+
+ public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
+ {
+ // In theory this should never be called because there is always only one lineup
+ var path = await GetXml(info.Path, CancellationToken.None).ConfigureAwait(false);
+ var reader = new XmlTvReader(path, GetLanguage());
+ var results = reader.GetChannels();
+
+ // Should this method be async?
+ return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
+ }
+
+ public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ // In theory this should never be called because there is always only one lineup
+ var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false);
+ var reader = new XmlTvReader(path, GetLanguage());
+ var results = reader.GetChannels();
+
+ // Should this method be async?
+ return results.Select(c => new ChannelInfo()
+ {
+ Id = c.Id,
+ Name = c.DisplayName,
+ ImageUrl = c.Icon != null && !String.IsNullOrEmpty(c.Icon.Source) ? c.Icon.Source : null,
+ Number = c.Id
+
+ }).ToList();
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs b/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs
new file mode 100644
index 000000000..a338ae23a
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class LiveStreamHelper
+ {
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly ILogger _logger;
+
+ public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger)
+ {
+ _mediaEncoder = mediaEncoder;
+ _logger = logger;
+ }
+
+ public async Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, CancellationToken cancellationToken)
+ {
+ var originalRuntime = mediaSource.RunTimeTicks;
+
+ var now = DateTime.UtcNow;
+
+ var info = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest
+ {
+ InputPath = mediaSource.Path,
+ Protocol = mediaSource.Protocol,
+ MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
+ ExtractChapters = false,
+ AnalyzeDurationSections = 2
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ _logger.Info("Live tv media info probe took {0} seconds", (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
+
+ mediaSource.Bitrate = info.Bitrate;
+ mediaSource.Container = info.Container;
+ mediaSource.Formats = info.Formats;
+ mediaSource.MediaStreams = info.MediaStreams;
+ mediaSource.RunTimeTicks = info.RunTimeTicks;
+ mediaSource.Size = info.Size;
+ mediaSource.Timestamp = info.Timestamp;
+ mediaSource.Video3DFormat = info.Video3DFormat;
+ mediaSource.VideoType = info.VideoType;
+
+ mediaSource.DefaultSubtitleStreamIndex = null;
+
+ // Null this out so that it will be treated like a live stream
+ if (!originalRuntime.HasValue)
+ {
+ mediaSource.RunTimeTicks = null;
+ }
+
+ var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio);
+
+ if (audioStream == null || audioStream.Index == -1)
+ {
+ mediaSource.DefaultAudioStreamIndex = null;
+ }
+ else
+ {
+ mediaSource.DefaultAudioStreamIndex = audioStream.Index;
+ }
+
+ var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video);
+ if (videoStream != null)
+ {
+ if (!videoStream.BitRate.HasValue)
+ {
+ var width = videoStream.Width ?? 1920;
+
+ if (width >= 1900)
+ {
+ videoStream.BitRate = 8000000;
+ }
+
+ else if (width >= 1260)
+ {
+ videoStream.BitRate = 3000000;
+ }
+
+ else if (width >= 700)
+ {
+ videoStream.BitRate = 1000000;
+ }
+ }
+
+ // This is coming up false and preventing stream copy
+ videoStream.IsAVC = null;
+ }
+
+ // Try to estimate this
+ if (!mediaSource.Bitrate.HasValue)
+ {
+ var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum();
+
+ if (total > 0)
+ {
+ mediaSource.Bitrate = total;
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs
new file mode 100644
index 000000000..2be642737
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs
@@ -0,0 +1,21 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.LiveTv;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class LiveTvConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ ConfigurationType = typeof(LiveTvOptions),
+ Key = "livetv"
+ }
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs
new file mode 100644
index 000000000..e73378dde
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs
@@ -0,0 +1,563 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Dto;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class LiveTvDtoService
+ {
+ private readonly ILogger _logger;
+ private readonly IImageProcessor _imageProcessor;
+
+ private readonly IUserDataManager _userDataManager;
+ private readonly IDtoService _dtoService;
+ private readonly IApplicationHost _appHost;
+ private readonly ILibraryManager _libraryManager;
+
+ public LiveTvDtoService(IDtoService dtoService, IUserDataManager userDataManager, IImageProcessor imageProcessor, ILogger logger, IApplicationHost appHost, ILibraryManager libraryManager)
+ {
+ _dtoService = dtoService;
+ _userDataManager = userDataManager;
+ _imageProcessor = imageProcessor;
+ _logger = logger;
+ _appHost = appHost;
+ _libraryManager = libraryManager;
+ }
+
+ public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, LiveTvChannel channel)
+ {
+ var dto = new TimerInfoDto
+ {
+ Id = GetInternalTimerId(service.Name, info.Id).ToString("N"),
+ Overview = info.Overview,
+ EndDate = info.EndDate,
+ Name = info.Name,
+ StartDate = info.StartDate,
+ ExternalId = info.Id,
+ ChannelId = GetInternalChannelId(service.Name, info.ChannelId).ToString("N"),
+ Status = info.Status,
+ SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(service.Name, info.SeriesTimerId).ToString("N"),
+ PrePaddingSeconds = info.PrePaddingSeconds,
+ PostPaddingSeconds = info.PostPaddingSeconds,
+ IsPostPaddingRequired = info.IsPostPaddingRequired,
+ IsPrePaddingRequired = info.IsPrePaddingRequired,
+ KeepUntil = info.KeepUntil,
+ ExternalChannelId = info.ChannelId,
+ ExternalSeriesTimerId = info.SeriesTimerId,
+ ServiceName = service.Name,
+ ExternalProgramId = info.ProgramId,
+ Priority = info.Priority,
+ RunTimeTicks = (info.EndDate - info.StartDate).Ticks,
+ ServerId = _appHost.SystemId
+ };
+
+ if (!string.IsNullOrEmpty(info.ProgramId))
+ {
+ dto.ProgramId = GetInternalProgramId(service.Name, info.ProgramId).ToString("N");
+ }
+
+ if (program != null)
+ {
+ dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions());
+
+ if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error)
+ {
+ dto.ProgramInfo.TimerId = dto.Id;
+ dto.ProgramInfo.Status = info.Status.ToString();
+ }
+
+ dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId;
+
+ if (!string.IsNullOrWhiteSpace(info.SeriesTimerId))
+ {
+ FillImages(dto.ProgramInfo, info.Name, info.SeriesId);
+ }
+ }
+
+ if (channel != null)
+ {
+ dto.ChannelName = channel.Name;
+ }
+
+ return dto;
+ }
+
+ public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName)
+ {
+ var dto = new SeriesTimerInfoDto
+ {
+ Id = GetInternalSeriesTimerId(service.Name, info.Id).ToString("N"),
+ Overview = info.Overview,
+ EndDate = info.EndDate,
+ Name = info.Name,
+ StartDate = info.StartDate,
+ ExternalId = info.Id,
+ PrePaddingSeconds = info.PrePaddingSeconds,
+ PostPaddingSeconds = info.PostPaddingSeconds,
+ IsPostPaddingRequired = info.IsPostPaddingRequired,
+ IsPrePaddingRequired = info.IsPrePaddingRequired,
+ Days = info.Days,
+ Priority = info.Priority,
+ RecordAnyChannel = info.RecordAnyChannel,
+ RecordAnyTime = info.RecordAnyTime,
+ SkipEpisodesInLibrary = info.SkipEpisodesInLibrary,
+ KeepUpTo = info.KeepUpTo,
+ KeepUntil = info.KeepUntil,
+ RecordNewOnly = info.RecordNewOnly,
+ ExternalChannelId = info.ChannelId,
+ ExternalProgramId = info.ProgramId,
+ ServiceName = service.Name,
+ ChannelName = channelName,
+ ServerId = _appHost.SystemId
+ };
+
+ if (!string.IsNullOrEmpty(info.ChannelId))
+ {
+ dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId).ToString("N");
+ }
+
+ if (!string.IsNullOrEmpty(info.ProgramId))
+ {
+ dto.ProgramId = GetInternalProgramId(service.Name, info.ProgramId).ToString("N");
+ }
+
+ dto.DayPattern = info.Days == null ? null : GetDayPattern(info.Days);
+
+ FillImages(dto, info.Name, info.SeriesId);
+
+ return dto;
+ }
+
+ private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId)
+ {
+ var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new string[] { typeof(Series).Name },
+ Name = seriesName,
+ Limit = 1,
+ ImageTypes = new ImageType[] { ImageType.Thumb }
+
+ }).FirstOrDefault();
+
+ if (librarySeries != null)
+ {
+ var image = librarySeries.GetImageInfo(ImageType.Thumb, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image);
+ dto.ParentThumbItemId = librarySeries.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ image = librarySeries.GetImageInfo(ImageType.Backdrop, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentBackdropImageTags = new List<string>
+ {
+ _imageProcessor.GetImageCacheTag(librarySeries, image)
+ };
+ dto.ParentBackdropItemId = librarySeries.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(programSeriesId))
+ {
+ var program = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+ ExternalSeriesId = programSeriesId,
+ Limit = 1,
+ ImageTypes = new ImageType[] { ImageType.Primary }
+
+ }).FirstOrDefault();
+
+ if (program != null)
+ {
+ var image = program.GetImageInfo(ImageType.Primary, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image);
+ dto.ParentPrimaryImageItemId = program.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+
+ if (dto.ParentBackdropImageTags == null || dto.ParentBackdropImageTags.Count == 0)
+ {
+ image = program.GetImageInfo(ImageType.Backdrop, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentBackdropImageTags = new List<string>
+ {
+ _imageProcessor.GetImageCacheTag(program, image)
+ };
+ dto.ParentBackdropItemId = program.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId)
+ {
+ var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new string[] { typeof(Series).Name },
+ Name = seriesName,
+ Limit = 1,
+ ImageTypes = new ImageType[] { ImageType.Thumb }
+
+ }).FirstOrDefault();
+
+ if (librarySeries != null)
+ {
+ var image = librarySeries.GetImageInfo(ImageType.Thumb, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image);
+ dto.ParentThumbItemId = librarySeries.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ image = librarySeries.GetImageInfo(ImageType.Backdrop, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentBackdropImageTags = new List<string>
+ {
+ _imageProcessor.GetImageCacheTag(librarySeries, image)
+ };
+ dto.ParentBackdropItemId = librarySeries.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(programSeriesId))
+ {
+ var program = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+ ExternalSeriesId = programSeriesId,
+ Limit = 1,
+ ImageTypes = new ImageType[] { ImageType.Primary }
+
+ }).FirstOrDefault();
+
+ if (program != null)
+ {
+ var image = program.GetImageInfo(ImageType.Primary, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image);
+ dto.ParentPrimaryImageItemId = program.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+
+ if (dto.ParentBackdropImageTags == null || dto.ParentBackdropImageTags.Count == 0)
+ {
+ image = program.GetImageInfo(ImageType.Backdrop, 0);
+ if (image != null)
+ {
+ try
+ {
+ dto.ParentBackdropImageTags = new List<string>
+ {
+ _imageProcessor.GetImageCacheTag(program, image)
+ };
+ dto.ParentBackdropItemId = program.Id.ToString("N");
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public DayPattern? GetDayPattern(List<DayOfWeek> days)
+ {
+ DayPattern? pattern = null;
+
+ if (days.Count > 0)
+ {
+ if (days.Count == 7)
+ {
+ pattern = DayPattern.Daily;
+ }
+ else if (days.Count == 2)
+ {
+ if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday))
+ {
+ pattern = DayPattern.Weekends;
+ }
+ }
+ else if (days.Count == 5)
+ {
+ if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday))
+ {
+ pattern = DayPattern.Weekdays;
+ }
+ }
+ }
+
+ return pattern;
+ }
+
+ public LiveTvTunerInfoDto GetTunerInfoDto(string serviceName, LiveTvTunerInfo info, string channelName)
+ {
+ var dto = new LiveTvTunerInfoDto
+ {
+ Name = info.Name,
+ Id = info.Id,
+ Clients = info.Clients,
+ ProgramName = info.ProgramName,
+ SourceType = info.SourceType,
+ Status = info.Status,
+ ChannelName = channelName,
+ Url = info.Url,
+ CanReset = info.CanReset
+ };
+
+ if (!string.IsNullOrEmpty(info.ChannelId))
+ {
+ dto.ChannelId = GetInternalChannelId(serviceName, info.ChannelId).ToString("N");
+ }
+
+ if (!string.IsNullOrEmpty(info.RecordingId))
+ {
+ dto.RecordingId = GetInternalRecordingId(serviceName, info.RecordingId).ToString("N");
+ }
+
+ return dto;
+ }
+
+ internal string GetImageTag(IHasImages info)
+ {
+ try
+ {
+ return _imageProcessor.GetImageCacheTag(info, ImageType.Primary);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting image info for {0}", ex, info.Name);
+ }
+
+ return null;
+ }
+
+ private const string InternalVersionNumber = "4";
+
+ public Guid GetInternalChannelId(string serviceName, string externalId)
+ {
+ var name = serviceName + externalId + InternalVersionNumber;
+
+ return _libraryManager.GetNewItemId(name.ToLower(), typeof(LiveTvChannel));
+ }
+
+ public Guid GetInternalTimerId(string serviceName, string externalId)
+ {
+ var name = serviceName + externalId + InternalVersionNumber;
+
+ return name.ToLower().GetMD5();
+ }
+
+ public Guid GetInternalSeriesTimerId(string serviceName, string externalId)
+ {
+ var name = serviceName + externalId + InternalVersionNumber;
+
+ return name.ToLower().GetMD5();
+ }
+
+ public Guid GetInternalProgramId(string serviceName, string externalId)
+ {
+ var name = serviceName + externalId + InternalVersionNumber;
+
+ return _libraryManager.GetNewItemId(name.ToLower(), typeof(LiveTvProgram));
+ }
+
+ public Guid GetInternalRecordingId(string serviceName, string externalId)
+ {
+ var name = serviceName + externalId + InternalVersionNumber + "0";
+
+ return _libraryManager.GetNewItemId(name.ToLower(), typeof(ILiveTvRecording));
+ }
+
+ private string GetItemExternalId(BaseItem item)
+ {
+ var externalId = item.ExternalId;
+
+ if (string.IsNullOrWhiteSpace(externalId))
+ {
+ externalId = item.GetProviderId("ProviderExternalId");
+ }
+
+ return externalId;
+ }
+
+ public async Task<TimerInfo> GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken)
+ {
+ var info = new TimerInfo
+ {
+ Overview = dto.Overview,
+ EndDate = dto.EndDate,
+ Name = dto.Name,
+ StartDate = dto.StartDate,
+ Status = dto.Status,
+ PrePaddingSeconds = dto.PrePaddingSeconds,
+ PostPaddingSeconds = dto.PostPaddingSeconds,
+ IsPostPaddingRequired = dto.IsPostPaddingRequired,
+ IsPrePaddingRequired = dto.IsPrePaddingRequired,
+ KeepUntil = dto.KeepUntil,
+ Priority = dto.Priority,
+ SeriesTimerId = dto.ExternalSeriesTimerId,
+ ProgramId = dto.ExternalProgramId,
+ ChannelId = dto.ExternalChannelId,
+ Id = dto.ExternalId
+ };
+
+ // Convert internal server id's to external tv provider id's
+ if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id))
+ {
+ var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false);
+
+ info.Id = timer.ExternalId;
+ }
+
+ if (!string.IsNullOrEmpty(dto.ChannelId) && string.IsNullOrEmpty(info.ChannelId))
+ {
+ var channel = liveTv.GetInternalChannel(dto.ChannelId);
+
+ if (channel != null)
+ {
+ info.ChannelId = GetItemExternalId(channel);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId))
+ {
+ var program = liveTv.GetInternalProgram(dto.ProgramId);
+
+ if (program != null)
+ {
+ info.ProgramId = GetItemExternalId(program);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId))
+ {
+ var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false);
+
+ if (timer != null)
+ {
+ info.SeriesTimerId = timer.ExternalId;
+ }
+ }
+
+ return info;
+ }
+
+ public async Task<SeriesTimerInfo> GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken)
+ {
+ var info = new SeriesTimerInfo
+ {
+ Overview = dto.Overview,
+ EndDate = dto.EndDate,
+ Name = dto.Name,
+ StartDate = dto.StartDate,
+ PrePaddingSeconds = dto.PrePaddingSeconds,
+ PostPaddingSeconds = dto.PostPaddingSeconds,
+ IsPostPaddingRequired = dto.IsPostPaddingRequired,
+ IsPrePaddingRequired = dto.IsPrePaddingRequired,
+ Days = dto.Days,
+ Priority = dto.Priority,
+ RecordAnyChannel = dto.RecordAnyChannel,
+ RecordAnyTime = dto.RecordAnyTime,
+ SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary,
+ KeepUpTo = dto.KeepUpTo,
+ KeepUntil = dto.KeepUntil,
+ RecordNewOnly = dto.RecordNewOnly,
+ ProgramId = dto.ExternalProgramId,
+ ChannelId = dto.ExternalChannelId,
+ Id = dto.ExternalId
+ };
+
+ // Convert internal server id's to external tv provider id's
+ if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id))
+ {
+ var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false);
+
+ info.Id = timer.ExternalId;
+ }
+
+ if (!string.IsNullOrEmpty(dto.ChannelId) && string.IsNullOrEmpty(info.ChannelId))
+ {
+ var channel = liveTv.GetInternalChannel(dto.ChannelId);
+
+ if (channel != null)
+ {
+ info.ChannelId = GetItemExternalId(channel);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId))
+ {
+ var program = liveTv.GetInternalProgram(dto.ProgramId);
+
+ if (program != null)
+ {
+ info.ProgramId = GetItemExternalId(program);
+ }
+ }
+
+ return info;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs
new file mode 100644
index 000000000..265817cbe
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs
@@ -0,0 +1,3069 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+using Emby.Server.Implementations.LiveTv.Listings;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ /// <summary>
+ /// Class LiveTvManager
+ /// </summary>
+ public class LiveTvManager : ILiveTvManager, IDisposable
+ {
+ private readonly IServerConfigurationManager _config;
+ private readonly ILogger _logger;
+ private readonly IItemRepository _itemRepo;
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ITaskManager _taskManager;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IProviderManager _providerManager;
+ private readonly ISecurityManager _security;
+
+ private readonly IDtoService _dtoService;
+ private readonly ILocalizationManager _localization;
+
+ private readonly LiveTvDtoService _tvDtoService;
+
+ private readonly List<ILiveTvService> _services = new List<ILiveTvService>();
+
+ private readonly SemaphoreSlim _refreshRecordingsLock = new SemaphoreSlim(1, 1);
+
+ private readonly List<ITunerHost> _tunerHosts = new List<ITunerHost>();
+ private readonly List<IListingsProvider> _listingProviders = new List<IListingsProvider>();
+ private readonly IFileSystem _fileSystem;
+
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled;
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated;
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated;
+
+ public string GetEmbyTvActiveRecordingPath(string id)
+ {
+ return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id);
+ }
+
+ public Task<LiveStream> GetEmbyTvLiveStream(string id)
+ {
+ return EmbyTV.EmbyTV.Current.GetLiveStream(id);
+ }
+
+ public LiveTvManager(IApplicationHost appHost, IServerConfigurationManager config, ILogger logger, IItemRepository itemRepo, IImageProcessor imageProcessor, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, IJsonSerializer jsonSerializer, IProviderManager providerManager, IFileSystem fileSystem, ISecurityManager security)
+ {
+ _config = config;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _taskManager = taskManager;
+ _localization = localization;
+ _jsonSerializer = jsonSerializer;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ _security = security;
+ _dtoService = dtoService;
+ _userDataManager = userDataManager;
+
+ _tvDtoService = new LiveTvDtoService(dtoService, userDataManager, imageProcessor, logger, appHost, _libraryManager);
+ }
+
+ /// <summary>
+ /// Gets the services.
+ /// </summary>
+ /// <value>The services.</value>
+ public IReadOnlyList<ILiveTvService> Services
+ {
+ get { return _services; }
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ /// <summary>
+ /// Adds the parts.
+ /// </summary>
+ /// <param name="services">The services.</param>
+ /// <param name="tunerHosts">The tuner hosts.</param>
+ /// <param name="listingProviders">The listing providers.</param>
+ public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> listingProviders)
+ {
+ _services.AddRange(services);
+ _tunerHosts.AddRange(tunerHosts);
+ _listingProviders.AddRange(listingProviders);
+
+ foreach (var service in _services)
+ {
+ service.DataSourceChanged += service_DataSourceChanged;
+ service.RecordingStatusChanged += Service_RecordingStatusChanged;
+ }
+ }
+
+ private void Service_RecordingStatusChanged(object sender, RecordingStatusChangedEventArgs e)
+ {
+ _lastRecordingRefreshTime = DateTime.MinValue;
+ }
+
+ public List<ITunerHost> TunerHosts
+ {
+ get { return _tunerHosts; }
+ }
+
+ public List<IListingsProvider> ListingProviders
+ {
+ get { return _listingProviders; }
+ }
+
+ void service_DataSourceChanged(object sender, EventArgs e)
+ {
+ if (!_isDisposed)
+ {
+ _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>();
+ }
+ }
+
+ public async Task<QueryResult<LiveTvChannel>> GetInternalChannels(LiveTvChannelQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId);
+
+ var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ IsMovie = query.IsMovie,
+ IsNews = query.IsNews,
+ IsKids = query.IsKids,
+ IsSports = query.IsSports,
+ IsSeries = query.IsSeries,
+ IncludeItemTypes = new[] { typeof(LiveTvChannel).Name },
+ SortOrder = query.SortOrder ?? SortOrder.Ascending,
+ TopParentIds = new[] { topFolder.Id.ToString("N") },
+ IsFavorite = query.IsFavorite,
+ IsLiked = query.IsLiked,
+ StartIndex = query.StartIndex,
+ Limit = query.Limit
+ };
+
+ internalQuery.OrderBy.AddRange(query.SortBy.Select(i => new Tuple<string, SortOrder>(i, query.SortOrder ?? SortOrder.Ascending)));
+
+ if (query.EnableFavoriteSorting)
+ {
+ internalQuery.OrderBy.Insert(0, new Tuple<string, SortOrder>(ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending));
+ }
+
+ if (!internalQuery.OrderBy.Any(i => string.Equals(i.Item1, ItemSortBy.SortName, StringComparison.OrdinalIgnoreCase)))
+ {
+ internalQuery.OrderBy.Add(new Tuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending));
+ }
+
+ var channelResult = _libraryManager.GetItemsResult(internalQuery);
+
+ var result = new QueryResult<LiveTvChannel>
+ {
+ Items = channelResult.Items.Cast<LiveTvChannel>().ToArray(),
+ TotalRecordCount = channelResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ public LiveTvChannel GetInternalChannel(string id)
+ {
+ return GetInternalChannel(new Guid(id));
+ }
+
+ private LiveTvChannel GetInternalChannel(Guid id)
+ {
+ return _libraryManager.GetItemById(id) as LiveTvChannel;
+ }
+
+ internal LiveTvProgram GetInternalProgram(string id)
+ {
+ return _libraryManager.GetItemById(id) as LiveTvProgram;
+ }
+
+ internal LiveTvProgram GetInternalProgram(Guid id)
+ {
+ return _libraryManager.GetItemById(id) as LiveTvProgram;
+ }
+
+ public async Task<BaseItem> GetInternalRecording(string id, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ var result = await GetInternalRecordings(new RecordingQuery
+ {
+ Id = id
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ return result.Items.FirstOrDefault();
+ }
+
+ public async Task<MediaSourceInfo> GetRecordingStream(string id, CancellationToken cancellationToken)
+ {
+ var info = await GetLiveStream(id, null, false, cancellationToken).ConfigureAwait(false);
+
+ return info.Item1;
+ }
+
+ public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetChannelStream(string id, string mediaSourceId, CancellationToken cancellationToken)
+ {
+ return await GetLiveStream(id, mediaSourceId, true, cancellationToken).ConfigureAwait(false);
+ }
+
+ private string GetItemExternalId(BaseItem item)
+ {
+ var externalId = item.ExternalId;
+
+ if (string.IsNullOrWhiteSpace(externalId))
+ {
+ externalId = item.GetProviderId("ProviderExternalId");
+ }
+
+ return externalId;
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetRecordingMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var baseItem = (BaseItem)item;
+ var service = GetService(baseItem);
+
+ return await service.GetRecordingStreamMediaSources(GetItemExternalId(baseItem), cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var baseItem = (LiveTvChannel)item;
+ var service = GetService(baseItem);
+
+ var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
+
+ if (sources.Count == 0)
+ {
+ throw new NotImplementedException();
+ }
+
+ var list = sources.ToList();
+
+ foreach (var source in list)
+ {
+ Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
+ }
+
+ return list;
+ }
+
+ private ILiveTvService GetService(ILiveTvRecording item)
+ {
+ return GetService(item.ServiceName);
+ }
+
+ private ILiveTvService GetService(BaseItem item)
+ {
+ return GetService(item.ServiceName);
+ }
+
+ private ILiveTvService GetService(string name)
+ {
+ return _services.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStream(string id, string mediaSourceId, bool isChannel, CancellationToken cancellationToken)
+ {
+ if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSourceId = null;
+ }
+
+ MediaSourceInfo info;
+ bool isVideo;
+ ILiveTvService service;
+ IDirectStreamProvider directStreamProvider = null;
+
+ if (isChannel)
+ {
+ var channel = GetInternalChannel(id);
+ isVideo = channel.ChannelType == ChannelType.TV;
+ service = GetService(channel);
+ _logger.Info("Opening channel stream from {0}, external channel Id: {1}", service.Name, GetItemExternalId(channel));
+
+ var supportsManagedStream = service as ISupportsDirectStreamProvider;
+ if (supportsManagedStream != null)
+ {
+ var streamInfo = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(GetItemExternalId(channel), mediaSourceId, cancellationToken).ConfigureAwait(false);
+ info = streamInfo.Item1;
+ directStreamProvider = streamInfo.Item2;
+ }
+ else
+ {
+ info = await service.GetChannelStream(GetItemExternalId(channel), mediaSourceId, cancellationToken).ConfigureAwait(false);
+ }
+ info.RequiresClosing = true;
+
+ if (info.RequiresClosing)
+ {
+ var idPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_";
+
+ info.LiveStreamId = idPrefix + info.Id;
+ }
+ }
+ else
+ {
+ var recording = await GetInternalRecording(id, cancellationToken).ConfigureAwait(false);
+ isVideo = !string.Equals(recording.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase);
+ service = GetService(recording);
+
+ _logger.Info("Opening recording stream from {0}, external recording Id: {1}", service.Name, GetItemExternalId(recording));
+ info = await service.GetRecordingStream(GetItemExternalId(recording), null, cancellationToken).ConfigureAwait(false);
+ info.RequiresClosing = true;
+
+ if (info.RequiresClosing)
+ {
+ var idPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_";
+
+ info.LiveStreamId = idPrefix + info.Id;
+ }
+ }
+
+ _logger.Info("Live stream info: {0}", _jsonSerializer.SerializeToString(info));
+ Normalize(info, service, isVideo);
+
+ return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info, directStreamProvider);
+ }
+
+ private void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
+ {
+ if (mediaSource.MediaStreams.Count == 0)
+ {
+ if (isVideo)
+ {
+ mediaSource.MediaStreams.AddRange(new List<MediaStream>
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+
+ // Set to true if unknown to enable deinterlacing
+ IsInterlaced = true
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ });
+ }
+ else
+ {
+ mediaSource.MediaStreams.AddRange(new List<MediaStream>
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ });
+ }
+ }
+
+ // Clean some bad data coming from providers
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.BitRate.HasValue && stream.BitRate <= 0)
+ {
+ stream.BitRate = null;
+ }
+ if (stream.Channels.HasValue && stream.Channels <= 0)
+ {
+ stream.Channels = null;
+ }
+ if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0)
+ {
+ stream.AverageFrameRate = null;
+ }
+ if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0)
+ {
+ stream.RealFrameRate = null;
+ }
+ if (stream.Width.HasValue && stream.Width <= 0)
+ {
+ stream.Width = null;
+ }
+ if (stream.Height.HasValue && stream.Height <= 0)
+ {
+ stream.Height = null;
+ }
+ if (stream.SampleRate.HasValue && stream.SampleRate <= 0)
+ {
+ stream.SampleRate = null;
+ }
+ if (stream.Level.HasValue && stream.Level <= 0)
+ {
+ stream.Level = null;
+ }
+ }
+
+ var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList();
+
+ // If there are duplicate stream indexes, set them all to unknown
+ if (indexes.Count != mediaSource.MediaStreams.Count)
+ {
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ stream.Index = -1;
+ }
+ }
+
+ // Set the total bitrate if not already supplied
+ if (!mediaSource.Bitrate.HasValue)
+ {
+ var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum();
+
+ if (total > 0)
+ {
+ mediaSource.Bitrate = total;
+ }
+ }
+
+ if (!(service is EmbyTV.EmbyTV))
+ {
+ // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says
+ mediaSource.SupportsDirectStream = false;
+ mediaSource.SupportsTranscoding = true;
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
+ {
+ stream.NalLengthSize = "0";
+ }
+ }
+ }
+ }
+
+ private async Task<LiveTvChannel> GetChannel(ChannelInfo channelInfo, string serviceName, Guid parentFolderId, CancellationToken cancellationToken)
+ {
+ var isNew = false;
+ var forceUpdate = false;
+
+ var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
+
+ var item = _libraryManager.GetItemById(id) as LiveTvChannel;
+
+ if (item == null)
+ {
+ item = new LiveTvChannel
+ {
+ Name = channelInfo.Name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ };
+
+ isNew = true;
+ }
+
+ if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
+ {
+ isNew = true;
+ }
+ item.ExternalId = channelInfo.Id;
+
+ if (!item.ParentId.Equals(parentFolderId))
+ {
+ isNew = true;
+ }
+ item.ParentId = parentFolderId;
+
+ item.ChannelType = channelInfo.ChannelType;
+ item.ServiceName = serviceName;
+ item.Number = channelInfo.Number;
+
+ //if (!string.Equals(item.ProviderImageUrl, channelInfo.ImageUrl, StringComparison.OrdinalIgnoreCase))
+ //{
+ // isNew = true;
+ // replaceImages.Add(ImageType.Primary);
+ //}
+ //if (!string.Equals(item.ProviderImagePath, channelInfo.ImagePath, StringComparison.OrdinalIgnoreCase))
+ //{
+ // isNew = true;
+ // replaceImages.Add(ImageType.Primary);
+ //}
+
+ if (!item.HasImage(ImageType.Primary))
+ {
+ if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
+ {
+ item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
+ forceUpdate = true;
+ }
+ else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
+ {
+ item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
+ forceUpdate = true;
+ }
+ }
+
+ if (string.IsNullOrEmpty(item.Name))
+ {
+ item.Name = channelInfo.Name;
+ }
+
+ if (isNew)
+ {
+ await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false);
+ }
+ else if (forceUpdate)
+ {
+ await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
+
+ await item.RefreshMetadata(new MetadataRefreshOptions(_fileSystem)
+ {
+ ForceSave = isNew || forceUpdate
+
+ }, cancellationToken);
+
+ return item;
+ }
+
+ private Tuple<LiveTvProgram, bool, bool> GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel, ChannelType channelType, string serviceName, CancellationToken cancellationToken)
+ {
+ var id = _tvDtoService.GetInternalProgramId(serviceName, info.Id);
+
+ LiveTvProgram item = null;
+ allExistingPrograms.TryGetValue(id, out item);
+
+ var isNew = false;
+ var forceUpdate = false;
+
+ if (item == null)
+ {
+ isNew = true;
+ item = new LiveTvProgram
+ {
+ Name = info.Name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ ExternalEtag = info.Etag
+ };
+ }
+
+ var seriesId = info.SeriesId;
+
+ if (!item.ParentId.Equals(channel.Id))
+ {
+ forceUpdate = true;
+ }
+ item.ParentId = channel.Id;
+
+ //item.ChannelType = channelType;
+ if (!string.Equals(item.ServiceName, serviceName, StringComparison.Ordinal))
+ {
+ forceUpdate = true;
+ }
+ item.ServiceName = serviceName;
+
+ item.Audio = info.Audio;
+ item.ChannelId = channel.Id.ToString("N");
+ item.CommunityRating = item.CommunityRating ?? info.CommunityRating;
+
+ item.EpisodeTitle = info.EpisodeTitle;
+ item.ExternalId = info.Id;
+
+ if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
+ {
+ forceUpdate = true;
+ }
+ item.ExternalSeriesId = seriesId;
+
+ item.Genres = info.Genres;
+ item.IsHD = info.IsHD;
+ item.IsKids = info.IsKids;
+ item.IsLive = info.IsLive;
+ item.IsMovie = info.IsMovie;
+ item.IsNews = info.IsNews;
+ item.IsPremiere = info.IsPremiere;
+ item.IsRepeat = info.IsRepeat;
+ item.IsSeries = info.IsSeries;
+ item.IsSports = info.IsSports;
+ item.Name = info.Name;
+ item.OfficialRating = item.OfficialRating ?? info.OfficialRating;
+ item.Overview = item.Overview ?? info.Overview;
+ item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
+
+ if (item.StartDate != info.StartDate)
+ {
+ forceUpdate = true;
+ }
+ item.StartDate = info.StartDate;
+
+ if (item.EndDate != info.EndDate)
+ {
+ forceUpdate = true;
+ }
+ item.EndDate = info.EndDate;
+
+ item.HomePageUrl = info.HomePageUrl;
+
+ item.ProductionYear = info.ProductionYear;
+
+ if (!info.IsSeries || info.IsRepeat)
+ {
+ item.PremiereDate = info.OriginalAirDate;
+ }
+
+ item.IndexNumber = info.EpisodeNumber;
+ item.ParentIndexNumber = info.SeasonNumber;
+
+ if (!item.HasImage(ImageType.Primary))
+ {
+ if (!string.IsNullOrWhiteSpace(info.ImagePath))
+ {
+ item.SetImage(new ItemImageInfo
+ {
+ Path = info.ImagePath,
+ Type = ImageType.Primary,
+ IsPlaceholder = true
+ }, 0);
+ }
+ else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
+ {
+ item.SetImage(new ItemImageInfo
+ {
+ Path = info.ImageUrl,
+ Type = ImageType.Primary,
+ IsPlaceholder = true
+ }, 0);
+ }
+ }
+
+ var isUpdated = false;
+ if (isNew)
+ {
+ }
+ else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
+ {
+ isUpdated = true;
+ }
+ else
+ {
+ // Increment this whenver some internal change deems it necessary
+ var etag = info.Etag + "4";
+
+ if (!string.Equals(etag, item.ExternalEtag, StringComparison.OrdinalIgnoreCase))
+ {
+ item.ExternalEtag = etag;
+ isUpdated = true;
+ }
+ }
+
+ return new Tuple<LiveTvProgram, bool, bool>(item, isNew, isUpdated);
+ }
+
+ private async Task<Guid> CreateRecordingRecord(RecordingInfo info, string serviceName, Guid parentFolderId, CancellationToken cancellationToken)
+ {
+ var isNew = false;
+
+ var id = _tvDtoService.GetInternalRecordingId(serviceName, info.Id);
+
+ var item = _itemRepo.RetrieveItem(id);
+
+ if (item == null)
+ {
+ if (info.ChannelType == ChannelType.TV)
+ {
+ item = new LiveTvVideoRecording
+ {
+ Name = info.Name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ VideoType = VideoType.VideoFile
+ };
+ }
+ else
+ {
+ item = new LiveTvAudioRecording
+ {
+ Name = info.Name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+ }
+
+ isNew = true;
+ }
+
+ item.ChannelId = _tvDtoService.GetInternalChannelId(serviceName, info.ChannelId).ToString("N");
+ item.CommunityRating = info.CommunityRating;
+ item.OfficialRating = info.OfficialRating;
+ item.Overview = info.Overview;
+ item.EndDate = info.EndDate;
+ item.Genres = info.Genres;
+ item.PremiereDate = info.OriginalAirDate;
+
+ var recording = (ILiveTvRecording)item;
+
+ recording.ExternalId = info.Id;
+
+ var dataChanged = false;
+
+ recording.Audio = info.Audio;
+ recording.EndDate = info.EndDate;
+ recording.EpisodeTitle = info.EpisodeTitle;
+ recording.IsHD = info.IsHD;
+ recording.IsKids = info.IsKids;
+ recording.IsLive = info.IsLive;
+ recording.IsMovie = info.IsMovie;
+ recording.IsNews = info.IsNews;
+ recording.IsPremiere = info.IsPremiere;
+ recording.IsRepeat = info.IsRepeat;
+ recording.IsSports = info.IsSports;
+ recording.SeriesTimerId = info.SeriesTimerId;
+ recording.TimerId = info.TimerId;
+ recording.StartDate = info.StartDate;
+
+ if (!dataChanged)
+ {
+ dataChanged = recording.IsSeries != info.IsSeries;
+ }
+ recording.IsSeries = info.IsSeries;
+
+ if (!item.ParentId.Equals(parentFolderId))
+ {
+ dataChanged = true;
+ }
+ item.ParentId = parentFolderId;
+
+ if (!item.HasImage(ImageType.Primary))
+ {
+ if (!string.IsNullOrWhiteSpace(info.ImagePath))
+ {
+ item.SetImage(new ItemImageInfo
+ {
+ Path = info.ImagePath,
+ Type = ImageType.Primary,
+ IsPlaceholder = true
+ }, 0);
+ }
+ else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
+ {
+ item.SetImage(new ItemImageInfo
+ {
+ Path = info.ImageUrl,
+ Type = ImageType.Primary,
+ IsPlaceholder = true
+ }, 0);
+ }
+ }
+
+ var statusChanged = info.Status != recording.Status;
+
+ recording.Status = info.Status;
+
+ recording.ServiceName = serviceName;
+
+ if (!string.IsNullOrEmpty(info.Path))
+ {
+ if (!dataChanged)
+ {
+ dataChanged = !string.Equals(item.Path, info.Path);
+ }
+ var fileInfo = _fileSystem.GetFileInfo(info.Path);
+
+ recording.DateCreated = _fileSystem.GetCreationTimeUtc(fileInfo);
+ recording.DateModified = _fileSystem.GetLastWriteTimeUtc(fileInfo);
+ item.Path = info.Path;
+ }
+ else if (!string.IsNullOrEmpty(info.Url))
+ {
+ if (!dataChanged)
+ {
+ dataChanged = !string.Equals(item.Path, info.Url);
+ }
+ item.Path = info.Url;
+ }
+
+ var metadataRefreshMode = MetadataRefreshMode.Default;
+
+ if (isNew)
+ {
+ await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false);
+ }
+ else if (dataChanged || info.DateLastUpdated > recording.DateLastSaved || statusChanged)
+ {
+ metadataRefreshMode = MetadataRefreshMode.FullRefresh;
+ await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (info.Status != RecordingStatus.InProgress)
+ {
+ _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ MetadataRefreshMode = metadataRefreshMode
+ });
+ }
+
+ return item.Id;
+ }
+
+
+
+ private string GetExternalSeriesIdLegacy(BaseItem item)
+ {
+ return item.GetProviderId("ProviderExternalSeriesId");
+ }
+
+ public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
+ {
+ var program = GetInternalProgram(id);
+
+ var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user);
+
+ var list = new List<Tuple<BaseItemDto, string, string, string>>();
+
+ var externalSeriesId = program.ExternalSeriesId;
+
+ if (string.IsNullOrWhiteSpace(externalSeriesId))
+ {
+ externalSeriesId = GetExternalSeriesIdLegacy(program);
+ }
+
+ list.Add(new Tuple<BaseItemDto, string, string, string>(dto, program.ServiceName, GetItemExternalId(program), externalSeriesId));
+
+ await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false);
+
+ return dto;
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetPrograms(ProgramQuery query, DtoOptions options, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId);
+
+ var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+
+ if (query.SortBy.Length == 0)
+ {
+ // Unless something else was specified, order by start date to take advantage of a specialized index
+ query.SortBy = new[] { ItemSortBy.StartDate };
+ }
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+ MinEndDate = query.MinEndDate,
+ MinStartDate = query.MinStartDate,
+ MaxEndDate = query.MaxEndDate,
+ MaxStartDate = query.MaxStartDate,
+ ChannelIds = query.ChannelIds,
+ IsMovie = query.IsMovie,
+ IsSeries = query.IsSeries,
+ IsSports = query.IsSports,
+ IsKids = query.IsKids,
+ IsNews = query.IsNews,
+ Genres = query.Genres,
+ StartIndex = query.StartIndex,
+ Limit = query.Limit,
+ SortBy = query.SortBy,
+ SortOrder = query.SortOrder ?? SortOrder.Ascending,
+ EnableTotalRecordCount = query.EnableTotalRecordCount,
+ TopParentIds = new[] { topFolder.Id.ToString("N") },
+ Name = query.Name,
+ DtoOptions = options
+ };
+
+ if (!string.IsNullOrWhiteSpace(query.SeriesTimerId))
+ {
+ var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery { }, cancellationToken).ConfigureAwait(false);
+ var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.ServiceName, i.Id).ToString("N"), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase));
+ if (seriesTimer != null)
+ {
+ internalQuery.ExternalSeriesId = seriesTimer.SeriesId;
+
+ if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId))
+ {
+ // Better to return nothing than every program in the database
+ return new QueryResult<BaseItemDto>();
+ }
+ }
+ else
+ {
+ // Better to return nothing than every program in the database
+ return new QueryResult<BaseItemDto>();
+ }
+ }
+
+ if (query.HasAired.HasValue)
+ {
+ if (query.HasAired.Value)
+ {
+ internalQuery.MaxEndDate = DateTime.UtcNow;
+ }
+ else
+ {
+ internalQuery.MinEndDate = DateTime.UtcNow;
+ }
+ }
+
+ var queryResult = _libraryManager.QueryItems(internalQuery);
+
+ RemoveFields(options);
+
+ var returnArray = (await _dtoService.GetBaseItemDtos(queryResult.Items, options, user).ConfigureAwait(false)).ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = queryResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ public async Task<QueryResult<LiveTvProgram>> GetRecommendedProgramsInternal(RecommendedProgramQuery query, DtoOptions options, CancellationToken cancellationToken)
+ {
+ var user = _userManager.GetUserById(query.UserId);
+
+ var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+ IsAiring = query.IsAiring,
+ IsNews = query.IsNews,
+ IsMovie = query.IsMovie,
+ IsSeries = query.IsSeries,
+ IsSports = query.IsSports,
+ IsKids = query.IsKids,
+ EnableTotalRecordCount = query.EnableTotalRecordCount,
+ SortBy = new[] { ItemSortBy.StartDate },
+ TopParentIds = new[] { topFolder.Id.ToString("N") },
+ DtoOptions = options
+ };
+
+ if (query.Limit.HasValue)
+ {
+ internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200);
+ }
+
+ if (query.HasAired.HasValue)
+ {
+ if (query.HasAired.Value)
+ {
+ internalQuery.MaxEndDate = DateTime.UtcNow;
+ }
+ else
+ {
+ internalQuery.MinEndDate = DateTime.UtcNow;
+ }
+ }
+
+ IEnumerable<LiveTvProgram> programs = _libraryManager.QueryItems(internalQuery).Items.Cast<LiveTvProgram>();
+
+ var programList = programs.ToList();
+
+ var factorChannelWatchCount = (query.IsAiring ?? false) || (query.IsKids ?? false) || (query.IsSports ?? false) || (query.IsMovie ?? false) || (query.IsNews ?? false) || (query.IsSeries ?? false);
+
+ programs = programList.OrderBy(i => i.StartDate.Date)
+ .ThenByDescending(i => GetRecommendationScore(i, user.Id, factorChannelWatchCount))
+ .ThenBy(i => i.StartDate);
+
+ if (query.Limit.HasValue)
+ {
+ programs = programs.Take(query.Limit.Value);
+ }
+
+ programList = programs.ToList();
+
+ var returnArray = programList.ToArray();
+
+ var result = new QueryResult<LiveTvProgram>
+ {
+ Items = returnArray,
+ TotalRecordCount = returnArray.Length
+ };
+
+ return result;
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetRecommendedPrograms(RecommendedProgramQuery query, DtoOptions options, CancellationToken cancellationToken)
+ {
+ var internalResult = await GetRecommendedProgramsInternal(query, options, cancellationToken).ConfigureAwait(false);
+
+ var user = _userManager.GetUserById(query.UserId);
+
+ RemoveFields(options);
+
+ var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray();
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+
+ return result;
+ }
+
+ private int GetRecommendationScore(LiveTvProgram program, Guid userId, bool factorChannelWatchCount)
+ {
+ var score = 0;
+
+ if (program.IsLive)
+ {
+ score++;
+ }
+
+ if (program.IsSeries && !program.IsRepeat)
+ {
+ score++;
+ }
+
+ var channel = GetInternalChannel(program.ChannelId);
+
+ var channelUserdata = _userDataManager.GetUserData(userId, channel);
+
+ if (channelUserdata.Likes ?? false)
+ {
+ score += 2;
+ }
+ else if (!(channelUserdata.Likes ?? true))
+ {
+ score -= 2;
+ }
+
+ if (channelUserdata.IsFavorite)
+ {
+ score += 3;
+ }
+
+ if (factorChannelWatchCount)
+ {
+ score += channelUserdata.PlayCount;
+ }
+
+ return score;
+ }
+
+ private async Task AddRecordingInfo(IEnumerable<Tuple<BaseItemDto, string, string, string>> programs, CancellationToken cancellationToken)
+ {
+ var timers = new Dictionary<string, List<TimerInfo>>();
+ var seriesTimers = new Dictionary<string, List<SeriesTimerInfo>>();
+
+ foreach (var programTuple in programs)
+ {
+ var program = programTuple.Item1;
+ var serviceName = programTuple.Item2;
+ var externalProgramId = programTuple.Item3;
+ string externalSeriesId = programTuple.Item4;
+
+ if (string.IsNullOrWhiteSpace(serviceName))
+ {
+ continue;
+ }
+
+ List<TimerInfo> timerList;
+ if (!timers.TryGetValue(serviceName, out timerList))
+ {
+ try
+ {
+ var tempTimers = await GetService(serviceName).GetTimersAsync(cancellationToken).ConfigureAwait(false);
+ timers[serviceName] = timerList = tempTimers.ToList();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting timer infos", ex);
+ timers[serviceName] = timerList = new List<TimerInfo>();
+ }
+ }
+
+ var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase));
+ var foundSeriesTimer = false;
+
+ if (timer != null)
+ {
+ if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error)
+ {
+ program.TimerId = _tvDtoService.GetInternalTimerId(serviceName, timer.Id)
+ .ToString("N");
+
+ program.Status = timer.Status.ToString();
+ }
+
+ if (!string.IsNullOrEmpty(timer.SeriesTimerId))
+ {
+ program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(serviceName, timer.SeriesTimerId)
+ .ToString("N");
+
+ foundSeriesTimer = true;
+ }
+ }
+
+ if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId))
+ {
+ continue;
+ }
+
+ List<SeriesTimerInfo> seriesTimerList;
+ if (!seriesTimers.TryGetValue(serviceName, out seriesTimerList))
+ {
+ try
+ {
+ var tempTimers = await GetService(serviceName).GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+ seriesTimers[serviceName] = seriesTimerList = tempTimers.ToList();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting series timer infos", ex);
+ seriesTimers[serviceName] = seriesTimerList = new List<SeriesTimerInfo>();
+ }
+ }
+
+ var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase));
+
+ if (seriesTimer != null)
+ {
+ program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(serviceName, seriesTimer.Id)
+ .ToString("N");
+ }
+ }
+ }
+
+ internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return RefreshChannelsInternal(progress, cancellationToken);
+ }
+
+ private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ EmbyTV.EmbyTV.Current.CreateRecordingFolders();
+
+ var numComplete = 0;
+ double progressPerService = _services.Count == 0
+ ? 0
+ : 1 / _services.Count;
+
+ var newChannelIdList = new List<Guid>();
+ var newProgramIdList = new List<Guid>();
+
+ foreach (var service in _services)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _logger.Debug("Refreshing guide from {0}", service.Name);
+
+ try
+ {
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p => progress.Report(p * progressPerService));
+
+ var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
+
+ newChannelIdList.AddRange(idList.Item1);
+ newProgramIdList.AddRange(idList.Item2);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing channels for service", ex);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= _services.Count;
+
+ progress.Report(100 * percent);
+ }
+
+ await CleanDatabaseInternal(newChannelIdList, new[] { typeof(LiveTvChannel).Name }, progress, cancellationToken).ConfigureAwait(false);
+ await CleanDatabaseInternal(newProgramIdList, new[] { typeof(LiveTvProgram).Name }, progress, cancellationToken).ConfigureAwait(false);
+
+ var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
+
+ if (coreService != null)
+ {
+ await coreService.RefreshSeriesTimers(cancellationToken, new Progress<double>()).ConfigureAwait(false);
+ await coreService.RefreshTimers(cancellationToken, new Progress<double>()).ConfigureAwait(false);
+ }
+
+ // Load these now which will prefetch metadata
+ var dtoOptions = new DtoOptions();
+ dtoOptions.Fields.Remove(ItemFields.SyncInfo);
+ dtoOptions.Fields.Remove(ItemFields.BasicSyncInfo);
+ await GetRecordings(new RecordingQuery(), dtoOptions, cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ progress.Report(10);
+
+ var allChannels = await GetChannels(service, cancellationToken).ConfigureAwait(false);
+ var allChannelsList = allChannels.ToList();
+
+ var list = new List<LiveTvChannel>();
+
+ var numComplete = 0;
+ var parentFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+ var parentFolderId = parentFolder.Id;
+
+ foreach (var channelInfo in allChannelsList)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolderId, cancellationToken).ConfigureAwait(false);
+
+ list.Add(item);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting channel information for {0}", ex, channelInfo.Item2.Name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= allChannelsList.Count;
+
+ progress.Report(5 * percent + 10);
+ }
+
+ progress.Report(15);
+
+ numComplete = 0;
+ var programs = new List<Guid>();
+ var channels = new List<Guid>();
+
+ var guideDays = GetGuideDays();
+
+ _logger.Info("Refreshing guide with {0} days of guide data", guideDays);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var currentChannel in list)
+ {
+ channels.Add(currentChannel.Id);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var start = DateTime.UtcNow.AddHours(-1);
+ var end = start.AddDays(guideDays);
+
+ var isMovie = false;
+ var isSports = false;
+ var isNews = false;
+ var isKids = false;
+ var iSSeries = false;
+
+ var channelPrograms = await service.GetProgramsAsync(GetItemExternalId(currentChannel), start, end, cancellationToken).ConfigureAwait(false);
+
+ var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+
+ IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name },
+ ChannelIds = new string[] { currentChannel.Id.ToString("N") }
+
+ }).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
+
+ var newPrograms = new List<LiveTvProgram>();
+ var updatedPrograms = new List<LiveTvProgram>();
+
+ foreach (var program in channelPrograms)
+ {
+ var programTuple = GetProgram(program, existingPrograms, currentChannel, currentChannel.ChannelType, service.Name, cancellationToken);
+ var programItem = programTuple.Item1;
+
+ if (programTuple.Item2)
+ {
+ newPrograms.Add(programItem);
+ }
+ else if (programTuple.Item3)
+ {
+ updatedPrograms.Add(programItem);
+ }
+
+ programs.Add(programItem.Id);
+
+ if (program.IsMovie)
+ {
+ isMovie = true;
+ }
+
+ if (program.IsSeries)
+ {
+ iSSeries = true;
+ }
+
+ if (program.IsSports)
+ {
+ isSports = true;
+ }
+
+ if (program.IsNews)
+ {
+ isNews = true;
+ }
+
+ if (program.IsKids)
+ {
+ isKids = true;
+ }
+ }
+
+ _logger.Debug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
+
+ if (newPrograms.Count > 0)
+ {
+ await _libraryManager.CreateItems(newPrograms, cancellationToken).ConfigureAwait(false);
+ }
+
+ // TODO: Do this in bulk
+ foreach (var program in updatedPrograms)
+ {
+ await _libraryManager.UpdateItem(program, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var program in newPrograms)
+ {
+ _providerManager.QueueRefresh(program.Id, new MetadataRefreshOptions(_fileSystem));
+ }
+ foreach (var program in updatedPrograms)
+ {
+ _providerManager.QueueRefresh(program.Id, new MetadataRefreshOptions(_fileSystem));
+ }
+
+ currentChannel.IsMovie = isMovie;
+ currentChannel.IsNews = isNews;
+ currentChannel.IsSports = isSports;
+ currentChannel.IsKids = isKids;
+ currentChannel.IsSeries = iSSeries;
+
+ await currentChannel.UpdateToRepository(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting programs for channel {0}", ex, currentChannel.Name);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= allChannelsList.Count;
+
+ progress.Report(80 * percent + 10);
+ }
+ progress.Report(100);
+
+ return new Tuple<List<Guid>, List<Guid>>(channels, programs);
+ }
+
+ private async Task CleanDatabaseInternal(List<Guid> currentIdList, string[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
+ {
+ IncludeItemTypes = validTypes
+
+ }).ToList();
+
+ var numComplete = 0;
+
+ foreach (var itemId in list)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (itemId == Guid.Empty)
+ {
+ // Somehow some invalid data got into the db. It probably predates the boundary checking
+ continue;
+ }
+
+ if (!currentIdList.Contains(itemId))
+ {
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item != null)
+ {
+ await _libraryManager.DeleteItem(item, new DeleteOptions
+ {
+ DeleteFileLocation = false
+
+ }).ConfigureAwait(false);
+ }
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= list.Count;
+
+ progress.Report(100 * percent);
+ }
+ }
+
+ private const int MaxGuideDays = 14;
+ private double GetGuideDays()
+ {
+ var config = GetConfiguration();
+
+ if (config.GuideDays.HasValue)
+ {
+ return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays));
+ }
+
+ return 7;
+ }
+
+ private async Task<IEnumerable<Tuple<string, ChannelInfo>>> GetChannels(ILiveTvService service, CancellationToken cancellationToken)
+ {
+ var channels = await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false);
+
+ return channels.Select(i => new Tuple<string, ChannelInfo>(service.Name, i));
+ }
+
+ private DateTime _lastRecordingRefreshTime;
+ private async Task RefreshRecordings(CancellationToken cancellationToken)
+ {
+ const int cacheMinutes = 3;
+
+ if ((DateTime.UtcNow - _lastRecordingRefreshTime).TotalMinutes < cacheMinutes)
+ {
+ return;
+ }
+
+ await _refreshRecordingsLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ if ((DateTime.UtcNow - _lastRecordingRefreshTime).TotalMinutes < cacheMinutes)
+ {
+ return;
+ }
+
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetRecordingsAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<RecordingInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting recordings", ex);
+ return new List<Tuple<RecordingInfo, ILiveTvService>>();
+ }
+ });
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var folder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+ var parentFolderId = folder.Id;
+
+ var recordingTasks = results.SelectMany(i => i.ToList()).Select(i => CreateRecordingRecord(i.Item1, i.Item2.Name, parentFolderId, cancellationToken));
+
+ var idList = await Task.WhenAll(recordingTasks).ConfigureAwait(false);
+
+ await CleanDatabaseInternal(idList.ToList(), new[] { typeof(LiveTvVideoRecording).Name, typeof(LiveTvAudioRecording).Name }, new Progress<double>(), cancellationToken).ConfigureAwait(false);
+
+ _lastRecordingRefreshTime = DateTime.UtcNow;
+ }
+ finally
+ {
+ _refreshRecordingsLock.Release();
+ }
+ }
+
+ private QueryResult<BaseItem> GetEmbyRecordings(RecordingQuery query, DtoOptions dtoOptions, User user)
+ {
+ if (user == null)
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ if ((query.IsInProgress ?? false))
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
+ .SelectMany(i => i.Locations)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(i => _libraryManager.FindByPath(i, true))
+ .Where(i => i != null)
+ .Where(i => i.IsVisibleStandalone(user))
+ .ToList();
+
+ if (folders.Count == 0)
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ var includeItemTypes = new List<string>();
+ var excludeItemTypes = new List<string>();
+ var genres = new List<string>();
+
+ if (query.IsMovie.HasValue)
+ {
+ if (query.IsMovie.Value)
+ {
+ includeItemTypes.Add(typeof(Movie).Name);
+ }
+ else
+ {
+ excludeItemTypes.Add(typeof(Movie).Name);
+ }
+ }
+ if (query.IsSeries.HasValue)
+ {
+ if (query.IsSeries.Value)
+ {
+ includeItemTypes.Add(typeof(Episode).Name);
+ }
+ else
+ {
+ excludeItemTypes.Add(typeof(Episode).Name);
+ }
+ }
+ if (query.IsSports.HasValue)
+ {
+ if (query.IsSports.Value)
+ {
+ genres.Add("Sports");
+ }
+ }
+ if (query.IsKids.HasValue)
+ {
+ if (query.IsKids.Value)
+ {
+ genres.Add("Kids");
+ genres.Add("Children");
+ genres.Add("Family");
+ }
+ }
+
+ return _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+ {
+ MediaTypes = new[] { MediaType.Video },
+ Recursive = true,
+ AncestorIds = folders.Select(i => i.Id.ToString("N")).ToArray(),
+ IsFolder = false,
+ ExcludeLocationTypes = new[] { LocationType.Virtual },
+ Limit = query.Limit,
+ SortBy = new[] { ItemSortBy.DateCreated },
+ SortOrder = SortOrder.Descending,
+ EnableTotalRecordCount = query.EnableTotalRecordCount,
+ IncludeItemTypes = includeItemTypes.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ Genres = genres.ToArray(),
+ DtoOptions = dtoOptions
+ });
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetRecordingSeries(RecordingQuery query, DtoOptions options, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId);
+ if (user != null && !IsLiveTvEnabled(user))
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ if (_services.Count > 1)
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ if (user == null || (query.IsInProgress ?? false))
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
+ .SelectMany(i => i.Locations)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(i => _libraryManager.FindByPath(i, true))
+ .Where(i => i != null)
+ .Where(i => i.IsVisibleStandalone(user))
+ .ToList();
+
+ if (folders.Count == 0)
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ var includeItemTypes = new List<string>();
+ var excludeItemTypes = new List<string>();
+
+ includeItemTypes.Add(typeof(Series).Name);
+
+ var internalResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+ {
+ Recursive = true,
+ AncestorIds = folders.Select(i => i.Id.ToString("N")).ToArray(),
+ Limit = query.Limit,
+ SortBy = new[] { ItemSortBy.DateCreated },
+ SortOrder = SortOrder.Descending,
+ EnableTotalRecordCount = query.EnableTotalRecordCount,
+ IncludeItemTypes = includeItemTypes.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray()
+ });
+
+ RemoveFields(options);
+
+ var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray();
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+ }
+
+ public async Task<QueryResult<BaseItem>> GetInternalRecordings(RecordingQuery query, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId);
+ if (user != null && !IsLiveTvEnabled(user))
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ if (_services.Count == 1 && !(query.IsInProgress ?? false))
+ {
+ return GetEmbyRecordings(query, new DtoOptions(), user);
+ }
+
+ await RefreshRecordings(cancellationToken).ConfigureAwait(false);
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvVideoRecording).Name, typeof(LiveTvAudioRecording).Name }
+ };
+
+ if (!string.IsNullOrEmpty(query.ChannelId))
+ {
+ internalQuery.ChannelIds = new[] { query.ChannelId };
+ }
+
+ var queryResult = _libraryManager.GetItemList(internalQuery);
+ IEnumerable<ILiveTvRecording> recordings = queryResult.Cast<ILiveTvRecording>();
+
+ if (!string.IsNullOrWhiteSpace(query.Id))
+ {
+ var guid = new Guid(query.Id);
+
+ recordings = recordings
+ .Where(i => i.Id == guid);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.GroupId))
+ {
+ var guid = new Guid(query.GroupId);
+
+ recordings = recordings.Where(i => GetRecordingGroupIds(i).Contains(guid));
+ }
+
+ if (query.IsInProgress.HasValue)
+ {
+ var val = query.IsInProgress.Value;
+ recordings = recordings.Where(i => i.Status == RecordingStatus.InProgress == val);
+ }
+
+ if (query.Status.HasValue)
+ {
+ var val = query.Status.Value;
+ recordings = recordings.Where(i => i.Status == val);
+ }
+
+ if (query.IsMovie.HasValue)
+ {
+ var val = query.IsMovie.Value;
+ recordings = recordings.Where(i => i.IsMovie == val);
+ }
+
+ if (query.IsNews.HasValue)
+ {
+ var val = query.IsNews.Value;
+ recordings = recordings.Where(i => i.IsNews == val);
+ }
+
+ if (query.IsSeries.HasValue)
+ {
+ var val = query.IsSeries.Value;
+ recordings = recordings.Where(i => i.IsSeries == val);
+ }
+
+ if (query.IsKids.HasValue)
+ {
+ var val = query.IsKids.Value;
+ recordings = recordings.Where(i => i.IsKids == val);
+ }
+
+ if (query.IsSports.HasValue)
+ {
+ var val = query.IsSports.Value;
+ recordings = recordings.Where(i => i.IsSports == val);
+ }
+
+ if (!string.IsNullOrEmpty(query.SeriesTimerId))
+ {
+ var guid = new Guid(query.SeriesTimerId);
+
+ recordings = recordings
+ .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.ServiceName, i.SeriesTimerId) == guid);
+ }
+
+ recordings = recordings.OrderByDescending(i => i.StartDate);
+
+ var entityList = recordings.ToList();
+ IEnumerable<ILiveTvRecording> entities = entityList;
+
+ if (query.StartIndex.HasValue)
+ {
+ entities = entities.Skip(query.StartIndex.Value);
+ }
+
+ if (query.Limit.HasValue)
+ {
+ entities = entities.Take(query.Limit.Value);
+ }
+
+ return new QueryResult<BaseItem>
+ {
+ Items = entities.Cast<BaseItem>().ToArray(),
+ TotalRecordCount = entityList.Count
+ };
+ }
+
+ public async Task AddInfoToProgramDto(List<Tuple<BaseItem, BaseItemDto>> tuples, List<ItemFields> fields, User user = null)
+ {
+ var programTuples = new List<Tuple<BaseItemDto, string, string, string>>();
+
+ foreach (var tuple in tuples)
+ {
+ var program = (LiveTvProgram)tuple.Item1;
+ var dto = tuple.Item2;
+
+ dto.StartDate = program.StartDate;
+ dto.EpisodeTitle = program.EpisodeTitle;
+
+ if (program.IsRepeat)
+ {
+ dto.IsRepeat = program.IsRepeat;
+ }
+ if (program.IsMovie)
+ {
+ dto.IsMovie = program.IsMovie;
+ }
+ if (program.IsSeries)
+ {
+ dto.IsSeries = program.IsSeries;
+ }
+ if (program.IsSports)
+ {
+ dto.IsSports = program.IsSports;
+ }
+ if (program.IsLive)
+ {
+ dto.IsLive = program.IsLive;
+ }
+ if (program.IsNews)
+ {
+ dto.IsNews = program.IsNews;
+ }
+ if (program.IsKids)
+ {
+ dto.IsKids = program.IsKids;
+ }
+ if (program.IsPremiere)
+ {
+ dto.IsPremiere = program.IsPremiere;
+ }
+
+ if (fields.Contains(ItemFields.ChannelInfo))
+ {
+ var channel = GetInternalChannel(program.ChannelId);
+
+ if (channel != null)
+ {
+ dto.ChannelName = channel.Name;
+ dto.MediaType = channel.MediaType;
+ dto.ChannelNumber = channel.Number;
+
+ if (channel.HasImage(ImageType.Primary))
+ {
+ dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(channel);
+ }
+ }
+ }
+
+ var serviceName = program.ServiceName;
+
+ if (fields.Contains(ItemFields.ServiceName))
+ {
+ dto.ServiceName = serviceName;
+ }
+
+ var externalSeriesId = program.ExternalSeriesId;
+
+ if (string.IsNullOrWhiteSpace(externalSeriesId))
+ {
+ externalSeriesId = GetExternalSeriesIdLegacy(program);
+ }
+
+ programTuples.Add(new Tuple<BaseItemDto, string, string, string>(dto, serviceName, GetItemExternalId(program), externalSeriesId));
+ }
+
+ await AddRecordingInfo(programTuples, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, User user = null)
+ {
+ var recording = (ILiveTvRecording)item;
+ var service = GetService(recording);
+
+ var channel = string.IsNullOrWhiteSpace(recording.ChannelId) ? null : GetInternalChannel(recording.ChannelId);
+
+ var info = recording;
+
+ dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
+ ? null
+ : _tvDtoService.GetInternalSeriesTimerId(service.Name, info.SeriesTimerId).ToString("N");
+
+ dto.TimerId = string.IsNullOrEmpty(info.TimerId)
+ ? null
+ : _tvDtoService.GetInternalTimerId(service.Name, info.TimerId).ToString("N");
+
+ dto.StartDate = info.StartDate;
+ dto.RecordingStatus = info.Status;
+ dto.IsRepeat = info.IsRepeat;
+ dto.EpisodeTitle = info.EpisodeTitle;
+ dto.IsMovie = info.IsMovie;
+ dto.IsSeries = info.IsSeries;
+ dto.IsSports = info.IsSports;
+ dto.IsLive = info.IsLive;
+ dto.IsNews = info.IsNews;
+ dto.IsKids = info.IsKids;
+ dto.IsPremiere = info.IsPremiere;
+
+ dto.CanDelete = user == null
+ ? recording.CanDelete()
+ : recording.CanDelete(user);
+
+ if (dto.MediaSources == null)
+ {
+ dto.MediaSources = recording.GetMediaSources(true).ToList();
+ }
+
+ if (dto.MediaStreams == null)
+ {
+ dto.MediaStreams = dto.MediaSources.SelectMany(i => i.MediaStreams).ToList();
+ }
+
+ if (info.Status == RecordingStatus.InProgress && info.EndDate.HasValue)
+ {
+ var now = DateTime.UtcNow.Ticks;
+ var start = info.StartDate.Ticks;
+ var end = info.EndDate.Value.Ticks;
+
+ var pct = now - start;
+ pct /= end;
+ pct *= 100;
+ dto.CompletionPercentage = pct;
+ }
+
+ if (channel != null)
+ {
+ dto.ChannelName = channel.Name;
+
+ if (channel.HasImage(ImageType.Primary))
+ {
+ dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(channel);
+ }
+ }
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetRecordings(RecordingQuery query, DtoOptions options, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId);
+
+ var internalResult = await GetInternalRecordings(query, cancellationToken).ConfigureAwait(false);
+
+ RemoveFields(options);
+
+ var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray();
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = internalResult.TotalRecordCount
+ };
+ }
+
+ public async Task<QueryResult<TimerInfoDto>> GetTimers(TimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<TimerInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting recordings", ex);
+ return new List<Tuple<TimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (query.IsActive.HasValue)
+ {
+ if (query.IsActive.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.InProgress);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.InProgress);
+ }
+ }
+
+ if (query.IsScheduled.HasValue)
+ {
+ if (query.IsScheduled.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.New);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.New);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(query.ChannelId))
+ {
+ var guid = new Guid(query.ChannelId);
+ timers = timers.Where(i => guid == _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId));
+ }
+
+ if (!string.IsNullOrEmpty(query.SeriesTimerId))
+ {
+ var guid = new Guid(query.SeriesTimerId);
+
+ timers = timers
+ .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.Item2.Name, i.Item1.SeriesTimerId) == guid);
+ }
+
+ if (!string.IsNullOrEmpty(query.Id))
+ {
+ var guid = new Guid(query.Id);
+
+ timers = timers
+ .Where(i => _tvDtoService.GetInternalTimerId(i.Item2.Name, i.Item1.Id) == guid);
+ }
+
+ var returnList = new List<TimerInfoDto>();
+
+ foreach (var i in timers)
+ {
+ var program = string.IsNullOrEmpty(i.Item1.ProgramId) ?
+ null :
+ GetInternalProgram(_tvDtoService.GetInternalProgramId(i.Item2.Name, i.Item1.ProgramId).ToString("N"));
+
+ var channel = string.IsNullOrEmpty(i.Item1.ChannelId) ? null : GetInternalChannel(_tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId));
+
+ returnList.Add(_tvDtoService.GetTimerInfoDto(i.Item1, i.Item2, program, channel));
+ }
+
+ var returnArray = returnList
+ .OrderBy(i => i.StartDate)
+ .ToArray();
+
+ return new QueryResult<TimerInfoDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = returnArray.Length
+ };
+ }
+
+ public Task OnRecordingFileDeleted(BaseItem recording)
+ {
+ var service = GetService(recording);
+
+ if (service is EmbyTV.EmbyTV)
+ {
+ // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says
+ return service.DeleteRecordingAsync(GetItemExternalId(recording), CancellationToken.None);
+ }
+
+ return Task.FromResult(true);
+ }
+
+ public async Task DeleteRecording(string recordingId)
+ {
+ var recording = await GetInternalRecording(recordingId, CancellationToken.None).ConfigureAwait(false);
+
+ if (recording == null)
+ {
+ throw new ResourceNotFoundException(string.Format("Recording with Id {0} not found", recordingId));
+ }
+
+ await DeleteRecording((BaseItem)recording).ConfigureAwait(false);
+ }
+
+ public async Task DeleteRecording(BaseItem recording)
+ {
+ var service = GetService(recording.ServiceName);
+
+ try
+ {
+ await service.DeleteRecordingAsync(GetItemExternalId(recording), CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (ResourceNotFoundException)
+ {
+
+ }
+
+ _lastRecordingRefreshTime = DateTime.MinValue;
+
+ // This is the responsibility of the live tv service
+ await _libraryManager.DeleteItem((BaseItem)recording, new DeleteOptions
+ {
+ DeleteFileLocation = false
+
+ }).ConfigureAwait(false);
+
+ _lastRecordingRefreshTime = DateTime.MinValue;
+ }
+
+ public async Task CancelTimer(string id)
+ {
+ var timer = await GetTimer(id, CancellationToken.None).ConfigureAwait(false);
+
+ if (timer == null)
+ {
+ throw new ResourceNotFoundException(string.Format("Timer with Id {0} not found", id));
+ }
+
+ var service = GetService(timer.ServiceName);
+
+ await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
+ _lastRecordingRefreshTime = DateTime.MinValue;
+
+ EventHelper.QueueEventIfNotNull(TimerCancelled, this, new GenericEventArgs<TimerEventInfo>
+ {
+ Argument = new TimerEventInfo
+ {
+ Id = id
+ }
+ }, _logger);
+ }
+
+ public async Task CancelSeriesTimer(string id)
+ {
+ var timer = await GetSeriesTimer(id, CancellationToken.None).ConfigureAwait(false);
+
+ if (timer == null)
+ {
+ throw new ResourceNotFoundException(string.Format("Timer with Id {0} not found", id));
+ }
+
+ var service = GetService(timer.ServiceName);
+
+ await service.CancelSeriesTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
+ _lastRecordingRefreshTime = DateTime.MinValue;
+
+ EventHelper.QueueEventIfNotNull(SeriesTimerCancelled, this, new GenericEventArgs<TimerEventInfo>
+ {
+ Argument = new TimerEventInfo
+ {
+ Id = id
+ }
+ }, _logger);
+ }
+
+ public async Task<BaseItemDto> GetRecording(string id, DtoOptions options, CancellationToken cancellationToken, User user = null)
+ {
+ var item = await GetInternalRecording(id, cancellationToken).ConfigureAwait(false);
+
+ if (item == null)
+ {
+ return null;
+ }
+
+ return _dtoService.GetBaseItemDto((BaseItem)item, options, user);
+ }
+
+ public async Task<TimerInfoDto> GetTimer(string id, CancellationToken cancellationToken)
+ {
+ var results = await GetTimers(new TimerQuery
+ {
+ Id = id
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public async Task<SeriesTimerInfoDto> GetSeriesTimer(string id, CancellationToken cancellationToken)
+ {
+ var results = await GetSeriesTimers(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false);
+
+ return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task<QueryResult<SeriesTimerInfo>> GetSeriesTimersInternal(SeriesTimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r =>
+ {
+ r.ServiceName = i.Name;
+ return new Tuple<SeriesTimerInfo, ILiveTvService>(r, i);
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting recordings", ex);
+ return new List<Tuple<SeriesTimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase))
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) :
+ timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name);
+ }
+ else
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderByStringDescending(i => i.Item1.Name) :
+ timers.OrderByString(i => i.Item1.Name);
+ }
+
+ var returnArray = timers
+ .Select(i =>
+ {
+ return i.Item1;
+
+ })
+ .ToArray();
+
+ return new QueryResult<SeriesTimerInfo>
+ {
+ Items = returnArray,
+ TotalRecordCount = returnArray.Length
+ };
+ }
+
+ public async Task<QueryResult<SeriesTimerInfoDto>> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<SeriesTimerInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting recordings", ex);
+ return new List<Tuple<SeriesTimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase))
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) :
+ timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name);
+ }
+ else
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderByStringDescending(i => i.Item1.Name) :
+ timers.OrderByString(i => i.Item1.Name);
+ }
+
+ var returnArray = timers
+ .Select(i =>
+ {
+ string channelName = null;
+
+ if (!string.IsNullOrEmpty(i.Item1.ChannelId))
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId);
+ var channel = GetInternalChannel(internalChannelId);
+ channelName = channel == null ? null : channel.Name;
+ }
+
+ return _tvDtoService.GetSeriesTimerInfoDto(i.Item1, i.Item2, channelName);
+
+ })
+ .ToArray();
+
+ return new QueryResult<SeriesTimerInfoDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = returnArray.Length
+ };
+ }
+
+ public void AddChannelInfo(List<Tuple<BaseItemDto, LiveTvChannel>> tuples, DtoOptions options, User user)
+ {
+ var now = DateTime.UtcNow;
+
+ var channelIds = tuples.Select(i => i.Item2.Id.ToString("N")).Distinct().ToArray();
+
+ var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+ ChannelIds = channelIds,
+ MaxStartDate = now,
+ MinEndDate = now,
+ Limit = channelIds.Length,
+ SortBy = new[] { "StartDate" },
+ TopParentIds = new[] { GetInternalLiveTvFolder(CancellationToken.None).Result.Id.ToString("N") }
+
+ }).ToList() : new List<BaseItem>();
+
+ RemoveFields(options);
+
+ foreach (var tuple in tuples)
+ {
+ var dto = tuple.Item1;
+ var channel = tuple.Item2;
+
+ dto.Number = channel.Number;
+ dto.ChannelNumber = channel.Number;
+ dto.ChannelType = channel.ChannelType;
+ dto.ServiceName = channel.ServiceName;
+
+ if (options.Fields.Contains(ItemFields.MediaSources))
+ {
+ dto.MediaSources = channel.GetMediaSources(true).ToList();
+ }
+
+ if (options.AddCurrentProgram)
+ {
+ var channelIdString = channel.Id.ToString("N");
+ var currentProgram = programs.FirstOrDefault(i => string.Equals(i.ChannelId, channelIdString));
+
+ if (currentProgram != null)
+ {
+ dto.CurrentProgram = _dtoService.GetBaseItemDto(currentProgram, options, user);
+ }
+ }
+ }
+ }
+
+ private async Task<Tuple<SeriesTimerInfo, ILiveTvService>> GetNewTimerDefaultsInternal(CancellationToken cancellationToken, LiveTvProgram program = null)
+ {
+ var service = program != null && !string.IsNullOrWhiteSpace(program.ServiceName) ?
+ GetService(program) :
+ _services.FirstOrDefault();
+
+ ProgramInfo programInfo = null;
+
+ if (program != null)
+ {
+ var channel = GetInternalChannel(program.ChannelId);
+
+ programInfo = new ProgramInfo
+ {
+ Audio = program.Audio,
+ ChannelId = GetItemExternalId(channel),
+ CommunityRating = program.CommunityRating,
+ EndDate = program.EndDate ?? DateTime.MinValue,
+ EpisodeTitle = program.EpisodeTitle,
+ Genres = program.Genres,
+ Id = GetItemExternalId(program),
+ IsHD = program.IsHD,
+ IsKids = program.IsKids,
+ IsLive = program.IsLive,
+ IsMovie = program.IsMovie,
+ IsNews = program.IsNews,
+ IsPremiere = program.IsPremiere,
+ IsRepeat = program.IsRepeat,
+ IsSeries = program.IsSeries,
+ IsSports = program.IsSports,
+ OriginalAirDate = program.PremiereDate,
+ Overview = program.Overview,
+ StartDate = program.StartDate,
+ //ImagePath = program.ExternalImagePath,
+ Name = program.Name,
+ OfficialRating = program.OfficialRating
+ };
+ }
+
+ var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false);
+
+ info.RecordAnyTime = true;
+ info.Days = new List<DayOfWeek>
+ {
+ DayOfWeek.Sunday,
+ DayOfWeek.Monday,
+ DayOfWeek.Tuesday,
+ DayOfWeek.Wednesday,
+ DayOfWeek.Thursday,
+ DayOfWeek.Friday,
+ DayOfWeek.Saturday
+ };
+
+ info.Id = null;
+
+ return new Tuple<SeriesTimerInfo, ILiveTvService>(info, service);
+ }
+
+ public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(CancellationToken cancellationToken)
+ {
+ var info = await GetNewTimerDefaultsInternal(cancellationToken).ConfigureAwait(false);
+
+ var obj = _tvDtoService.GetSeriesTimerInfoDto(info.Item1, info.Item2, null);
+
+ return obj;
+ }
+
+ public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(string programId, CancellationToken cancellationToken)
+ {
+ var program = GetInternalProgram(programId);
+ var programDto = await GetProgram(programId, cancellationToken).ConfigureAwait(false);
+
+ var defaults = await GetNewTimerDefaultsInternal(cancellationToken, program).ConfigureAwait(false);
+ var info = _tvDtoService.GetSeriesTimerInfoDto(defaults.Item1, defaults.Item2, null);
+
+ info.Days = defaults.Item1.Days;
+
+ info.DayPattern = _tvDtoService.GetDayPattern(info.Days);
+
+ info.Name = program.Name;
+ info.ChannelId = programDto.ChannelId;
+ info.ChannelName = programDto.ChannelName;
+ info.StartDate = program.StartDate;
+ info.Name = program.Name;
+ info.Overview = program.Overview;
+ info.ProgramId = programDto.Id;
+ info.ExternalProgramId = GetItemExternalId(program);
+
+ if (program.EndDate.HasValue)
+ {
+ info.EndDate = program.EndDate.Value;
+ }
+
+ return info;
+ }
+
+ public async Task CreateTimer(TimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var service = GetService(timer.ServiceName);
+
+ var info = await _tvDtoService.GetTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false);
+
+ // Set priority from default values
+ var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false);
+ info.Priority = defaultValues.Priority;
+
+ string newTimerId = null;
+ var supportsNewTimerIds = service as ISupportsNewTimerIds;
+ if (supportsNewTimerIds != null)
+ {
+ newTimerId = await supportsNewTimerIds.CreateTimer(info, cancellationToken).ConfigureAwait(false);
+ newTimerId = _tvDtoService.GetInternalTimerId(timer.ServiceName, newTimerId).ToString("N");
+ }
+ else
+ {
+ await service.CreateTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ _lastRecordingRefreshTime = DateTime.MinValue;
+ _logger.Info("New recording scheduled");
+
+ EventHelper.QueueEventIfNotNull(TimerCreated, this, new GenericEventArgs<TimerEventInfo>
+ {
+ Argument = new TimerEventInfo
+ {
+ ProgramId = _tvDtoService.GetInternalProgramId(timer.ServiceName, info.ProgramId).ToString("N"),
+ Id = newTimerId
+ }
+ }, _logger);
+ }
+
+ public async Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var registration = await GetRegistrationInfo("seriesrecordings").ConfigureAwait(false);
+
+ if (!registration.IsValid)
+ {
+ _logger.Info("Creating series recordings requires an active Emby Premiere subscription.");
+ return;
+ }
+
+ var service = GetService(timer.ServiceName);
+
+ var info = await _tvDtoService.GetSeriesTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false);
+
+ // Set priority from default values
+ var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false);
+ info.Priority = defaultValues.Priority;
+
+ string newTimerId = null;
+ var supportsNewTimerIds = service as ISupportsNewTimerIds;
+ if (supportsNewTimerIds != null)
+ {
+ newTimerId = await supportsNewTimerIds.CreateSeriesTimer(info, cancellationToken).ConfigureAwait(false);
+ newTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.ServiceName, newTimerId).ToString("N");
+ }
+ else
+ {
+ await service.CreateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ _lastRecordingRefreshTime = DateTime.MinValue;
+
+ EventHelper.QueueEventIfNotNull(SeriesTimerCreated, this, new GenericEventArgs<TimerEventInfo>
+ {
+ Argument = new TimerEventInfo
+ {
+ ProgramId = _tvDtoService.GetInternalProgramId(timer.ServiceName, info.ProgramId).ToString("N"),
+ Id = newTimerId
+ }
+ }, _logger);
+ }
+
+ public async Task UpdateTimer(TimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var info = await _tvDtoService.GetTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false);
+
+ var service = GetService(timer.ServiceName);
+
+ await service.UpdateTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ _lastRecordingRefreshTime = DateTime.MinValue;
+ }
+
+ public async Task UpdateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var info = await _tvDtoService.GetSeriesTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false);
+
+ var service = GetService(timer.ServiceName);
+
+ await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ _lastRecordingRefreshTime = DateTime.MinValue;
+ }
+
+ private IEnumerable<string> GetRecordingGroupNames(ILiveTvRecording recording)
+ {
+ var list = new List<string>();
+
+ if (recording.IsSeries)
+ {
+ list.Add(recording.Name);
+ }
+
+ if (recording.IsKids)
+ {
+ list.Add("Kids");
+ }
+
+ if (recording.IsMovie)
+ {
+ list.Add("Movies");
+ }
+
+ if (recording.IsNews)
+ {
+ list.Add("News");
+ }
+
+ if (recording.IsSports)
+ {
+ list.Add("Sports");
+ }
+
+ if (!recording.IsSports && !recording.IsNews && !recording.IsMovie && !recording.IsKids && !recording.IsSeries)
+ {
+ list.Add("Others");
+ }
+
+ return list;
+ }
+
+ private List<Guid> GetRecordingGroupIds(ILiveTvRecording recording)
+ {
+ return GetRecordingGroupNames(recording).Select(i => i.ToLower()
+ .GetMD5())
+ .ToList();
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetRecordingGroups(RecordingGroupQuery query, CancellationToken cancellationToken)
+ {
+ var recordingResult = await GetInternalRecordings(new RecordingQuery
+ {
+ UserId = query.UserId
+
+ }, cancellationToken).ConfigureAwait(false);
+
+ var recordings = recordingResult.Items.OfType<ILiveTvRecording>().ToList();
+
+ var groups = new List<BaseItemDto>();
+
+ var series = recordings
+ .Where(i => i.IsSeries)
+ .ToLookup(i => i.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ groups.AddRange(series.OrderByString(i => i.Key).Select(i => new BaseItemDto
+ {
+ Name = i.Key,
+ RecordingCount = i.Count()
+ }));
+
+ groups.Add(new BaseItemDto
+ {
+ Name = "Kids",
+ RecordingCount = recordings.Count(i => i.IsKids)
+ });
+
+ groups.Add(new BaseItemDto
+ {
+ Name = "Movies",
+ RecordingCount = recordings.Count(i => i.IsMovie)
+ });
+
+ groups.Add(new BaseItemDto
+ {
+ Name = "News",
+ RecordingCount = recordings.Count(i => i.IsNews)
+ });
+
+ groups.Add(new BaseItemDto
+ {
+ Name = "Sports",
+ RecordingCount = recordings.Count(i => i.IsSports)
+ });
+
+ groups.Add(new BaseItemDto
+ {
+ Name = "Others",
+ RecordingCount = recordings.Count(i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries)
+ });
+
+ groups = groups
+ .Where(i => i.RecordingCount > 0)
+ .ToList();
+
+ foreach (var group in groups)
+ {
+ group.Id = group.Name.ToLower().GetMD5().ToString("N");
+ }
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = groups.ToArray(),
+ TotalRecordCount = groups.Count
+ };
+ }
+
+ public async Task CloseLiveStream(string id)
+ {
+ var parts = id.Split(new[] { '_' }, 2);
+
+ var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N"), parts[0], StringComparison.OrdinalIgnoreCase));
+
+ if (service == null)
+ {
+ throw new ArgumentException("Service not found.");
+ }
+
+ id = parts[1];
+
+ _logger.Info("Closing live stream from {0}, stream Id: {1}", service.Name, id);
+
+ await service.CloseLiveStream(id, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ public GuideInfo GetGuideInfo()
+ {
+ var startDate = DateTime.UtcNow;
+ var endDate = startDate.AddDays(14);
+
+ return new GuideInfo
+ {
+ StartDate = startDate,
+ EndDate = endDate
+ };
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ private bool _isDisposed = false;
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ _isDisposed = true;
+ }
+ }
+
+ private async Task<IEnumerable<LiveTvServiceInfo>> GetServiceInfos(CancellationToken cancellationToken)
+ {
+ var tasks = Services.Select(i => GetServiceInfo(i, cancellationToken));
+
+ return await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ private async Task<LiveTvServiceInfo> GetServiceInfo(ILiveTvService service, CancellationToken cancellationToken)
+ {
+ var info = new LiveTvServiceInfo
+ {
+ Name = service.Name
+ };
+
+ var tunerIdPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_";
+
+ try
+ {
+ var statusInfo = await service.GetStatusInfoAsync(cancellationToken).ConfigureAwait(false);
+
+ info.Status = statusInfo.Status;
+ info.StatusMessage = statusInfo.StatusMessage;
+ info.Version = statusInfo.Version;
+ info.HasUpdateAvailable = statusInfo.HasUpdateAvailable;
+ info.HomePageUrl = service.HomePageUrl;
+ info.IsVisible = statusInfo.IsVisible;
+
+ info.Tuners = statusInfo.Tuners.Select(i =>
+ {
+ string channelName = null;
+
+ if (!string.IsNullOrEmpty(i.ChannelId))
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, i.ChannelId);
+ var channel = GetInternalChannel(internalChannelId);
+ channelName = channel == null ? null : channel.Name;
+ }
+
+ var dto = _tvDtoService.GetTunerInfoDto(service.Name, i, channelName);
+
+ dto.Id = tunerIdPrefix + dto.Id;
+
+ return dto;
+
+ }).ToList();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting service status info from {0}", ex, service.Name ?? string.Empty);
+
+ info.Status = LiveTvServiceStatus.Unavailable;
+ info.StatusMessage = ex.Message;
+ }
+
+ return info;
+ }
+
+ public async Task<LiveTvInfo> GetLiveTvInfo(CancellationToken cancellationToken)
+ {
+ var services = await GetServiceInfos(CancellationToken.None).ConfigureAwait(false);
+ var servicesList = services.ToList();
+
+ var info = new LiveTvInfo
+ {
+ Services = servicesList.ToList(),
+ IsEnabled = servicesList.Count > 0
+ };
+
+ info.EnabledUsers = _userManager.Users
+ .Where(IsLiveTvEnabled)
+ .Select(i => i.Id.ToString("N"))
+ .ToList();
+
+ return info;
+ }
+
+ private bool IsLiveTvEnabled(User user)
+ {
+ return user.Policy.EnableLiveTvAccess && (Services.Count > 1 || GetConfiguration().TunerHosts.Count(i => i.IsEnabled) > 0);
+ }
+
+ public IEnumerable<User> GetEnabledUsers()
+ {
+ return _userManager.Users
+ .Where(IsLiveTvEnabled);
+ }
+
+ /// <summary>
+ /// Resets the tuner.
+ /// </summary>
+ /// <param name="id">The identifier.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task ResetTuner(string id, CancellationToken cancellationToken)
+ {
+ var parts = id.Split(new[] { '_' }, 2);
+
+ var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N"), parts[0], StringComparison.OrdinalIgnoreCase));
+
+ if (service == null)
+ {
+ throw new ArgumentException("Service not found.");
+ }
+
+ return service.ResetTuner(parts[1], cancellationToken);
+ }
+
+ public async Task<BaseItemDto> GetLiveTvFolder(string userId, CancellationToken cancellationToken)
+ {
+ var user = string.IsNullOrEmpty(userId) ? null : _userManager.GetUserById(userId);
+
+ var folder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false);
+
+ return _dtoService.GetBaseItemDto(folder, new DtoOptions(), user);
+ }
+
+ private void RemoveFields(DtoOptions options)
+ {
+ options.Fields.Remove(ItemFields.CanDelete);
+ options.Fields.Remove(ItemFields.CanDownload);
+ options.Fields.Remove(ItemFields.DisplayPreferencesId);
+ options.Fields.Remove(ItemFields.Etag);
+ }
+
+ public async Task<Folder> GetInternalLiveTvFolder(CancellationToken cancellationToken)
+ {
+ var name = _localization.GetLocalizedString("ViewTypeLiveTV");
+ return await _libraryManager.GetNamedView(name, CollectionType.LiveTv, name, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true)
+ {
+ info = _jsonSerializer.DeserializeFromString<TunerHostInfo>(_jsonSerializer.SerializeToString(info));
+
+ var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ var configurable = provider as IConfigurableTunerHost;
+ if (configurable != null)
+ {
+ await configurable.Validate(info).ConfigureAwait(false);
+ }
+
+ var config = GetConfiguration();
+
+ var index = config.TunerHosts.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N");
+ config.TunerHosts.Add(info);
+ }
+ else
+ {
+ config.TunerHosts[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ if (dataSourceChanged)
+ {
+ _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>();
+ }
+
+ return info;
+ }
+
+ public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ info = _jsonSerializer.DeserializeFromString<ListingsProviderInfo>(_jsonSerializer.SerializeToString(info));
+
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
+
+ var config = GetConfiguration();
+
+ var index = config.ListingProviders.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N");
+ config.ListingProviders.Add(info);
+ }
+ else
+ {
+ config.ListingProviders[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>();
+
+ return info;
+ }
+
+ public void DeleteListingsProvider(string id)
+ {
+ var config = GetConfiguration();
+
+ config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>();
+ }
+
+ public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
+ {
+ var config = GetConfiguration();
+
+ var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
+ listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
+ {
+ var list = listingsProviderInfo.ChannelMappings.ToList();
+ list.Add(new NameValuePair
+ {
+ Name = tunerChannelNumber,
+ Value = providerChannelNumber
+ });
+ listingsProviderInfo.ChannelMappings = list.ToArray();
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var mappings = listingsProviderInfo.ChannelMappings.ToList();
+
+ var tunerChannelMappings =
+ tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList();
+
+ _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>();
+
+ return tunerChannelMappings.First(i => string.Equals(i.Number, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public TunerChannelMapping GetTunerChannelMapping(ChannelInfo channel, List<NameValuePair> mappings, List<ChannelInfo> providerChannels)
+ {
+ var result = new TunerChannelMapping
+ {
+ Name = channel.Number + " " + channel.Name,
+ Number = channel.Number
+ };
+
+ var mapping = mappings.FirstOrDefault(i => string.Equals(i.Name, channel.Number, StringComparison.OrdinalIgnoreCase));
+ var providerChannelNumber = channel.Number;
+
+ if (mapping != null)
+ {
+ providerChannelNumber = mapping.Value;
+ }
+
+ var providerChannel = providerChannels.FirstOrDefault(i => string.Equals(i.Number, providerChannelNumber, StringComparison.OrdinalIgnoreCase));
+
+ if (providerChannel != null)
+ {
+ result.ProviderChannelNumber = providerChannel.Number;
+ result.ProviderChannelName = providerChannel.Name;
+ }
+
+ return result;
+ }
+
+ public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location)
+ {
+ var config = GetConfiguration();
+
+ if (string.IsNullOrWhiteSpace(providerId))
+ {
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ return provider.GetLineups(null, country, location);
+ }
+ else
+ {
+ var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase));
+
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ return provider.GetLineups(info, country, location);
+ }
+ }
+
+ public Task<MBRegistrationRecord> GetRegistrationInfo(string feature)
+ {
+ if (string.Equals(feature, "seriesrecordings", StringComparison.OrdinalIgnoreCase))
+ {
+ feature = "embytvseriesrecordings";
+ }
+
+ if (string.Equals(feature, "dvr-l", StringComparison.OrdinalIgnoreCase))
+ {
+ var config = GetConfiguration();
+ if (config.TunerHosts.Count(i => i.IsEnabled) > 0 &&
+ config.ListingProviders.Count(i => (i.EnableAllTuners || i.EnabledTuners.Length > 0) && string.Equals(i.Type, SchedulesDirect.TypeName, StringComparison.OrdinalIgnoreCase)) > 0)
+ {
+ return Task.FromResult(new MBRegistrationRecord
+ {
+ IsRegistered = true,
+ IsValid = true
+ });
+ }
+ }
+
+ return _security.GetRegistrationStatus(feature);
+ }
+
+ public List<NameValuePair> GetSatIniMappings()
+ {
+ return new List<NameValuePair>();
+ //var names = GetType().Assembly.GetManifestResourceNames().Where(i => i.IndexOf("SatIp.ini", StringComparison.OrdinalIgnoreCase) != -1).ToList();
+
+ //return names.Select(GetSatIniMappings).Where(i => i != null).DistinctBy(i => i.Value.Split('|')[0]).ToList();
+ }
+
+ public NameValuePair GetSatIniMappings(string resource)
+ {
+ return new NameValuePair();
+ //using (var stream = GetType().Assembly.GetManifestResourceStream(resource))
+ //{
+ // using (var reader = new StreamReader(stream))
+ // {
+ // var parser = new StreamIniDataParser();
+ // IniData data = parser.ReadData(reader);
+
+ // var satType1 = data["SATTYPE"]["1"];
+ // var satType2 = data["SATTYPE"]["2"];
+
+ // if (string.IsNullOrWhiteSpace(satType2))
+ // {
+ // return null;
+ // }
+
+ // var srch = "SatIp.ini.";
+ // var filename = Path.GetFileName(resource);
+
+ // return new NameValuePair
+ // {
+ // Name = satType1 + " " + satType2,
+ // Value = satType2 + "|" + filename.Substring(filename.IndexOf(srch) + srch.Length)
+ // };
+ // }
+ //}
+ }
+
+ public Task<List<ChannelInfo>> GetSatChannelScanResult(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new List<ChannelInfo>());
+ //return new TunerHosts.SatIp.ChannelScan(_logger).Scan(info, cancellationToken);
+ }
+
+ public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken)
+ {
+ var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken);
+ }
+
+ public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken)
+ {
+ var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase));
+ return provider.GetChannels(info, cancellationToken);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
new file mode 100644
index 000000000..e0a35686e
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
@@ -0,0 +1,219 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Dlna;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class LiveTvMediaSourceProvider : IMediaSourceProvider
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly ILogger _logger;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IServerApplicationHost _appHost;
+
+ public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IJsonSerializer jsonSerializer, ILogManager logManager, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerApplicationHost appHost)
+ {
+ _liveTvManager = liveTvManager;
+ _jsonSerializer = jsonSerializer;
+ _mediaSourceManager = mediaSourceManager;
+ _mediaEncoder = mediaEncoder;
+ _appHost = appHost;
+ _logger = logManager.GetLogger(GetType().Name);
+ }
+
+ public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var baseItem = (BaseItem)item;
+
+ if (baseItem.SourceType == SourceType.LiveTV)
+ {
+ if (string.IsNullOrWhiteSpace(baseItem.Path))
+ {
+ return GetMediaSourcesInternal(item, cancellationToken);
+ }
+ }
+
+ return Task.FromResult<IEnumerable<MediaSourceInfo>>(new List<MediaSourceInfo>());
+ }
+
+ // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
+ private const char StreamIdDelimeter = '_';
+ private const string StreamIdDelimeterString = "_";
+
+ private async Task<IEnumerable<MediaSourceInfo>> GetMediaSourcesInternal(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ IEnumerable<MediaSourceInfo> sources;
+
+ var forceRequireOpening = false;
+
+ try
+ {
+ if (item is ILiveTvRecording)
+ {
+ sources = await _liveTvManager.GetRecordingMediaSources(item, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (NotImplementedException)
+ {
+ var hasMediaSources = (IHasMediaSources)item;
+
+ sources = _mediaSourceManager.GetStaticMediaSources(hasMediaSources, false)
+ .ToList();
+
+ forceRequireOpening = true;
+ }
+
+ var list = sources.ToList();
+ var serverUrl = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+
+ foreach (var source in list)
+ {
+ source.Type = MediaSourceType.Default;
+ source.BufferMs = source.BufferMs ?? 1500;
+
+ if (source.RequiresOpening || forceRequireOpening)
+ {
+ source.RequiresOpening = true;
+ }
+
+ if (source.RequiresOpening)
+ {
+ var openKeys = new List<string>();
+ openKeys.Add(item.GetType().Name);
+ openKeys.Add(item.Id.ToString("N"));
+ openKeys.Add(source.Id ?? string.Empty);
+ source.OpenToken = string.Join(StreamIdDelimeterString, openKeys.ToArray());
+ }
+
+ // Dummy this up so that direct play checks can still run
+ if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
+ {
+ source.Path = serverUrl;
+ }
+ }
+
+ _logger.Debug("MediaSources: {0}", _jsonSerializer.SerializeToString(list));
+
+ return list;
+ }
+
+ public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> OpenMediaSource(string openToken, CancellationToken cancellationToken)
+ {
+ MediaSourceInfo stream = null;
+ const bool isAudio = false;
+
+ var keys = openToken.Split(new[] { StreamIdDelimeter }, 3);
+ var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
+ IDirectStreamProvider directStreamProvider = null;
+
+ if (string.Equals(keys[0], typeof(LiveTvChannel).Name, StringComparison.OrdinalIgnoreCase))
+ {
+ var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, cancellationToken).ConfigureAwait(false);
+ stream = info.Item1;
+ directStreamProvider = info.Item2;
+ }
+ else
+ {
+ stream = await _liveTvManager.GetRecordingStream(keys[1], cancellationToken).ConfigureAwait(false);
+ }
+
+ try
+ {
+ if (!stream.SupportsProbing || stream.MediaStreams.Any(i => i.Index != -1))
+ {
+ await AddMediaInfo(stream, isAudio, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error probing live tv stream", ex);
+ }
+
+ return new Tuple<MediaSourceInfo, IDirectStreamProvider>(stream, directStreamProvider);
+ }
+
+ private async Task AddMediaInfo(MediaSourceInfo mediaSource, bool isAudio, CancellationToken cancellationToken)
+ {
+ mediaSource.DefaultSubtitleStreamIndex = null;
+
+ // Null this out so that it will be treated like a live stream
+ mediaSource.RunTimeTicks = null;
+
+ var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio);
+
+ if (audioStream == null || audioStream.Index == -1)
+ {
+ mediaSource.DefaultAudioStreamIndex = null;
+ }
+ else
+ {
+ mediaSource.DefaultAudioStreamIndex = audioStream.Index;
+ }
+
+ var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video);
+ if (videoStream != null)
+ {
+ if (!videoStream.BitRate.HasValue)
+ {
+ var width = videoStream.Width ?? 1920;
+
+ if (width >= 1900)
+ {
+ videoStream.BitRate = 8000000;
+ }
+
+ else if (width >= 1260)
+ {
+ videoStream.BitRate = 3000000;
+ }
+
+ else if (width >= 700)
+ {
+ videoStream.BitRate = 1000000;
+ }
+ }
+ }
+
+ // Try to estimate this
+ if (!mediaSource.Bitrate.HasValue)
+ {
+ var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum();
+
+ if (total > 0)
+ {
+ mediaSource.Bitrate = total;
+ }
+ }
+ }
+
+ public Task CloseMediaSource(string liveStreamId)
+ {
+ return _liveTvManager.CloseLiveStream(liveStreamId);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/ProgramImageProvider.cs b/Emby.Server.Implementations/LiveTv/ProgramImageProvider.cs
new file mode 100644
index 000000000..5a0389b16
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/ProgramImageProvider.cs
@@ -0,0 +1,100 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class ProgramImageProvider : IDynamicImageProvider, IHasItemChangeMonitor, IHasOrder
+ {
+ private readonly ILiveTvManager _liveTvManager;
+
+ public ProgramImageProvider(ILiveTvManager liveTvManager)
+ {
+ _liveTvManager = liveTvManager;
+ }
+
+ public IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new[] { ImageType.Primary };
+ }
+
+ private string GetItemExternalId(BaseItem item)
+ {
+ var externalId = item.ExternalId;
+
+ if (string.IsNullOrWhiteSpace(externalId))
+ {
+ externalId = item.GetProviderId("ProviderExternalId");
+ }
+
+ return externalId;
+ }
+
+ public async Task<DynamicImageResponse> GetImage(IHasImages item, ImageType type, CancellationToken cancellationToken)
+ {
+ var liveTvItem = (LiveTvProgram)item;
+
+ var imageResponse = new DynamicImageResponse();
+
+ var service = _liveTvManager.Services.FirstOrDefault(i => string.Equals(i.Name, liveTvItem.ServiceName, StringComparison.OrdinalIgnoreCase));
+
+ if (service != null)
+ {
+ try
+ {
+ var channel = _liveTvManager.GetInternalChannel(liveTvItem.ChannelId);
+
+ var response = await service.GetProgramImageAsync(GetItemExternalId(liveTvItem), GetItemExternalId(channel), cancellationToken).ConfigureAwait(false);
+
+ if (response != null)
+ {
+ imageResponse.HasImage = true;
+ imageResponse.Stream = response.Stream;
+ imageResponse.Format = response.Format;
+ }
+ }
+ catch (NotImplementedException)
+ {
+ }
+ }
+
+ return imageResponse;
+ }
+
+ public string Name
+ {
+ get { return "Live TV Service Provider"; }
+ }
+
+ public bool Supports(IHasImages item)
+ {
+ return item is LiveTvProgram;
+ }
+
+ public int Order
+ {
+ get
+ {
+ // Let the better providers run first
+ return 100;
+ }
+ }
+
+ public bool HasChanged(IHasMetadata item, IDirectoryService directoryService)
+ {
+ var liveTvItem = item as LiveTvProgram;
+
+ if (liveTvItem != null)
+ {
+ return !liveTvItem.HasImage(ImageType.Primary);
+ }
+ return false;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/RecordingImageProvider.cs b/Emby.Server.Implementations/LiveTv/RecordingImageProvider.cs
new file mode 100644
index 000000000..47663bdbc
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/RecordingImageProvider.cs
@@ -0,0 +1,82 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class RecordingImageProvider : IDynamicImageProvider, IHasItemChangeMonitor
+ {
+ private readonly ILiveTvManager _liveTvManager;
+
+ public RecordingImageProvider(ILiveTvManager liveTvManager)
+ {
+ _liveTvManager = liveTvManager;
+ }
+
+ public IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new[] { ImageType.Primary };
+ }
+
+ public async Task<DynamicImageResponse> GetImage(IHasImages item, ImageType type, CancellationToken cancellationToken)
+ {
+ var liveTvItem = (ILiveTvRecording)item;
+
+ var imageResponse = new DynamicImageResponse();
+
+ var service = _liveTvManager.Services.FirstOrDefault(i => string.Equals(i.Name, liveTvItem.ServiceName, StringComparison.OrdinalIgnoreCase));
+
+ if (service != null)
+ {
+ try
+ {
+ var response = await service.GetRecordingImageAsync(liveTvItem.ExternalId, cancellationToken).ConfigureAwait(false);
+
+ if (response != null)
+ {
+ imageResponse.HasImage = true;
+ imageResponse.Stream = response.Stream;
+ imageResponse.Format = response.Format;
+ }
+ }
+ catch (NotImplementedException)
+ {
+ }
+ }
+
+ return imageResponse;
+ }
+
+ public string Name
+ {
+ get { return "Live TV Service Provider"; }
+ }
+
+ public bool Supports(IHasImages item)
+ {
+ return item is ILiveTvRecording;
+ }
+
+ public int Order
+ {
+ get { return 0; }
+ }
+
+ public bool HasChanged(IHasMetadata item, IDirectoryService directoryService)
+ {
+ var liveTvItem = item as ILiveTvRecording;
+
+ if (liveTvItem != null)
+ {
+ return !liveTvItem.HasImage(ImageType.Primary);
+ }
+ return false;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs
new file mode 100644
index 000000000..f2806292d
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs
@@ -0,0 +1,83 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.LiveTv;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.LiveTv
+{
+ public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IConfigurationManager _config;
+
+ public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config)
+ {
+ _liveTvManager = liveTvManager;
+ _config = config;
+ }
+
+ public string Name
+ {
+ get { return "Refresh Guide"; }
+ }
+
+ public string Description
+ {
+ get { return "Downloads channel information from live tv services."; }
+ }
+
+ public string Category
+ {
+ get { return "Live TV"; }
+ }
+
+ public Task Execute(System.Threading.CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var manager = (LiveTvManager)_liveTvManager;
+
+ return manager.RefreshChannels(progress, cancellationToken);
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(12).Ticks}
+ };
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ public bool IsHidden
+ {
+ get { return _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Count(i => i.IsEnabled) == 0; }
+ }
+
+ public bool IsEnabled
+ {
+ get { return true; }
+ }
+
+ public bool IsLogged
+ {
+ get { return true; }
+ }
+
+ public string Key
+ {
+ get { return "RefreshGuide"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs
new file mode 100644
index 000000000..5fae3f666
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs
@@ -0,0 +1,259 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Serialization;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts
+{
+ public abstract class BaseTunerHost
+ {
+ protected readonly IServerConfigurationManager Config;
+ protected readonly ILogger Logger;
+ protected IJsonSerializer JsonSerializer;
+ protected readonly IMediaEncoder MediaEncoder;
+
+ private readonly ConcurrentDictionary<string, ChannelCache> _channelCache =
+ new ConcurrentDictionary<string, ChannelCache>(StringComparer.OrdinalIgnoreCase);
+
+ protected BaseTunerHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder)
+ {
+ Config = config;
+ Logger = logger;
+ JsonSerializer = jsonSerializer;
+ MediaEncoder = mediaEncoder;
+ }
+
+ protected abstract Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken);
+ public abstract string Type { get; }
+
+ public async Task<IEnumerable<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken)
+ {
+ ChannelCache cache = null;
+ var key = tuner.Id;
+
+ if (enableCache && !string.IsNullOrWhiteSpace(key) && _channelCache.TryGetValue(key, out cache))
+ {
+ if (DateTime.UtcNow - cache.Date < TimeSpan.FromMinutes(60))
+ {
+ return cache.Channels.ToList();
+ }
+ }
+
+ var result = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false);
+ var list = result.ToList();
+ Logger.Debug("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list));
+
+ if (!string.IsNullOrWhiteSpace(key) && list.Count > 0)
+ {
+ cache = cache ?? new ChannelCache();
+ cache.Date = DateTime.UtcNow;
+ cache.Channels = list;
+ _channelCache.AddOrUpdate(key, cache, (k, v) => cache);
+ }
+
+ return list;
+ }
+
+ protected virtual List<TunerHostInfo> GetTunerHosts()
+ {
+ return GetConfiguration().TunerHosts
+ .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ public async Task<IEnumerable<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ var hosts = GetTunerHosts();
+
+ foreach (var host in hosts)
+ {
+ try
+ {
+ var channels = await GetChannels(host, enableCache, cancellationToken).ConfigureAwait(false);
+ var newChannels = channels.Where(i => !list.Any(l => string.Equals(i.Id, l.Id, StringComparison.OrdinalIgnoreCase))).ToList();
+
+ list.AddRange(newChannels);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error getting channel list", ex);
+ }
+ }
+
+ return list;
+ }
+
+ protected abstract Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken);
+
+ public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ if (IsValidChannelId(channelId))
+ {
+ var hosts = GetTunerHosts();
+
+ var hostsWithChannel = new List<TunerHostInfo>();
+
+ foreach (var host in hosts)
+ {
+ try
+ {
+ var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
+
+ if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)))
+ {
+ hostsWithChannel.Add(host);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("Error getting channels", ex);
+ }
+ }
+
+ foreach (var host in hostsWithChannel)
+ {
+ try
+ {
+ // Check to make sure the tuner is available
+ // If there's only one tuner, don't bother with the check and just let the tuner be the one to throw an error
+ if (hostsWithChannel.Count > 1 &&
+ !await IsAvailable(host, channelId, cancellationToken).ConfigureAwait(false))
+ {
+ Logger.Error("Tuner is not currently available");
+ continue;
+ }
+
+ var mediaSources = await GetChannelStreamMediaSources(host, channelId, cancellationToken).ConfigureAwait(false);
+
+ // Prefix the id with the host Id so that we can easily find it
+ foreach (var mediaSource in mediaSources)
+ {
+ mediaSource.Id = host.Id + mediaSource.Id;
+ }
+
+ return mediaSources;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("Error opening tuner", ex);
+ }
+ }
+ }
+
+ return new List<MediaSourceInfo>();
+ }
+
+ protected abstract Task<LiveStream> GetChannelStream(TunerHostInfo tuner, string channelId, string streamId, CancellationToken cancellationToken);
+
+ public async Task<LiveStream> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ if (!IsValidChannelId(channelId))
+ {
+ throw new FileNotFoundException();
+ }
+
+ var hosts = GetTunerHosts();
+
+ var hostsWithChannel = new List<TunerHostInfo>();
+
+ foreach (var host in hosts)
+ {
+ if (string.IsNullOrWhiteSpace(streamId))
+ {
+ try
+ {
+ var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
+
+ if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)))
+ {
+ hostsWithChannel.Add(host);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("Error getting channels", ex);
+ }
+ }
+ else if (streamId.StartsWith(host.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ hostsWithChannel = new List<TunerHostInfo> { host };
+ streamId = streamId.Substring(host.Id.Length);
+ break;
+ }
+ }
+
+ foreach (var host in hostsWithChannel)
+ {
+ try
+ {
+ var liveStream = await GetChannelStream(host, channelId, streamId, cancellationToken).ConfigureAwait(false);
+ await liveStream.Open(cancellationToken).ConfigureAwait(false);
+ return liveStream;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("Error opening tuner", ex);
+ }
+ }
+
+ throw new LiveTvConflictException();
+ }
+
+ protected virtual bool EnableMediaProbing
+ {
+ get { return false; }
+ }
+
+ protected async Task<bool> IsAvailable(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await IsAvailableInternal(tuner, channelId, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error checking tuner availability", ex);
+ return false;
+ }
+ }
+
+ protected abstract Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken);
+
+ protected abstract bool IsValidChannelId(string channelId);
+
+ protected LiveTvOptions GetConfiguration()
+ {
+ return Config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ private class ChannelCache
+ {
+ public DateTime Date;
+ public List<ChannelInfo> Channels;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs
new file mode 100644
index 000000000..f2e48fbc0
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs
@@ -0,0 +1,159 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Serialization;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunDiscovery : IServerEntryPoint
+ {
+ private readonly IDeviceDiscovery _deviceDiscovery;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILogger _logger;
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _json;
+
+ public HdHomerunDiscovery(IDeviceDiscovery deviceDiscovery, IServerConfigurationManager config, ILogger logger, ILiveTvManager liveTvManager, IHttpClient httpClient, IJsonSerializer json)
+ {
+ _deviceDiscovery = deviceDiscovery;
+ _config = config;
+ _logger = logger;
+ _liveTvManager = liveTvManager;
+ _httpClient = httpClient;
+ _json = json;
+ }
+
+ public void Run()
+ {
+ _deviceDiscovery.DeviceDiscovered += _deviceDiscovery_DeviceDiscovered;
+ }
+
+ void _deviceDiscovery_DeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e)
+ {
+ string server = null;
+ var info = e.Argument;
+
+ if (info.Headers.TryGetValue("SERVER", out server) && server.IndexOf("HDHomeRun", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ string location;
+ if (info.Headers.TryGetValue("Location", out location))
+ {
+ //_logger.Debug("HdHomerun found at {0}", location);
+
+ // Just get the beginning of the url
+ Uri uri;
+ if (Uri.TryCreate(location, UriKind.Absolute, out uri))
+ {
+ var apiUrl = location.Replace(uri.LocalPath, String.Empty, StringComparison.OrdinalIgnoreCase)
+ .TrimEnd('/');
+
+ //_logger.Debug("HdHomerun api url: {0}", apiUrl);
+ AddDevice(apiUrl);
+ }
+ }
+ }
+ }
+
+ private async void AddDevice(string url)
+ {
+ await _semaphore.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ var options = GetConfiguration();
+
+ if (options.TunerHosts.Any(i =>
+ string.Equals(i.Type, HdHomerunHost.DeviceType, StringComparison.OrdinalIgnoreCase) &&
+ UriEquals(i.Url, url)))
+ {
+ return;
+ }
+
+ // Strip off the port
+ url = new Uri(url).GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Port, UriFormat.UriEscaped).TrimEnd('/');
+
+ // Test it by pulling down the lineup
+ using (var stream = await _httpClient.Get(new HttpRequestOptions
+ {
+ Url = string.Format("{0}/discover.json", url),
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ }))
+ {
+ var response = _json.DeserializeFromStream<HdHomerunHost.DiscoverResponse>(stream);
+
+ var existing = GetConfiguration().TunerHosts
+ .FirstOrDefault(i => string.Equals(i.Type, HdHomerunHost.DeviceType, StringComparison.OrdinalIgnoreCase) && string.Equals(i.DeviceId, response.DeviceID, StringComparison.OrdinalIgnoreCase));
+
+ if (existing == null)
+ {
+ await _liveTvManager.SaveTunerHost(new TunerHostInfo
+ {
+ Type = HdHomerunHost.DeviceType,
+ Url = url,
+ DataVersion = 1,
+ DeviceId = response.DeviceID
+
+ }).ConfigureAwait(false);
+ }
+ else
+ {
+ if (!string.Equals(existing.Url, url, StringComparison.OrdinalIgnoreCase))
+ {
+ existing.Url = url;
+ await _liveTvManager.SaveTunerHost(existing).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving device", ex);
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ private bool UriEquals(string savedUri, string location)
+ {
+ return string.Equals(NormalizeUrl(location), NormalizeUrl(savedUri), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private string NormalizeUrl(string url)
+ {
+ if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url;
+ }
+
+ url = url.TrimEnd('/');
+
+ // Strip off the port
+ return new Uri(url).GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Port, UriFormat.UriEscaped);
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
new file mode 100644
index 000000000..77efe8585
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
@@ -0,0 +1,577 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
+ {
+ private readonly IHttpClient _httpClient;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationHost _appHost;
+
+ public HdHomerunHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder, IHttpClient httpClient, IFileSystem fileSystem, IServerApplicationHost appHost)
+ : base(config, logger, jsonSerializer, mediaEncoder)
+ {
+ _httpClient = httpClient;
+ _fileSystem = fileSystem;
+ _appHost = appHost;
+ }
+
+ public string Name
+ {
+ get { return "HD Homerun"; }
+ }
+
+ public override string Type
+ {
+ get { return DeviceType; }
+ }
+
+ public static string DeviceType
+ {
+ get { return "hdhomerun"; }
+ }
+
+ private const string ChannelIdPrefix = "hdhr_";
+
+ private string GetChannelId(TunerHostInfo info, Channels i)
+ {
+ var id = ChannelIdPrefix + i.GuideNumber;
+
+ if (info.DataVersion >= 1)
+ {
+ id += '_' + (i.GuideName ?? string.Empty).GetMD5().ToString("N");
+ }
+
+ return id;
+ }
+
+ private async Task<IEnumerable<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var options = new HttpRequestOptions
+ {
+ Url = string.Format("{0}/lineup.json", GetApiUrl(info, false)),
+ CancellationToken = cancellationToken,
+ BufferContent = false
+ };
+ using (var stream = await _httpClient.Get(options))
+ {
+ var lineup = JsonSerializer.DeserializeFromStream<List<Channels>>(stream) ?? new List<Channels>();
+
+ if (info.ImportFavoritesOnly)
+ {
+ lineup = lineup.Where(i => i.Favorite).ToList();
+ }
+
+ return lineup.Where(i => !i.DRM).ToList();
+ }
+ }
+
+ protected override async Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var lineup = await GetLineup(info, cancellationToken).ConfigureAwait(false);
+
+ return lineup.Select(i => new ChannelInfo
+ {
+ Name = i.GuideName,
+ Number = i.GuideNumber,
+ Id = GetChannelId(info, i),
+ IsFavorite = i.Favorite,
+ TunerHostId = info.Id,
+ IsHD = i.HD == 1,
+ AudioCodec = i.AudioCodec,
+ VideoCodec = i.VideoCodec
+ });
+ }
+
+ private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
+ private async Task<string> GetModelInfo(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ lock (_modelCache)
+ {
+ DiscoverResponse response;
+ if (_modelCache.TryGetValue(info.Url, out response))
+ {
+ return response.ModelNumber;
+ }
+ }
+
+ try
+ {
+ using (var stream = await _httpClient.Get(new HttpRequestOptions()
+ {
+ Url = string.Format("{0}/discover.json", GetApiUrl(info, false)),
+ CancellationToken = cancellationToken,
+ CacheLength = TimeSpan.FromDays(1),
+ CacheMode = CacheMode.Unconditional,
+ TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(5).TotalMilliseconds),
+ BufferContent = false
+ }))
+ {
+ var response = JsonSerializer.DeserializeFromStream<DiscoverResponse>(stream);
+
+ lock (_modelCache)
+ {
+ _modelCache[info.Id] = response;
+ }
+
+ return response.ModelNumber;
+ }
+ }
+ catch (HttpException ex)
+ {
+ if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound)
+ {
+ var defaultValue = "HDHR";
+ // HDHR4 doesn't have this api
+ lock (_modelCache)
+ {
+ _modelCache[info.Id] = new DiscoverResponse
+ {
+ ModelNumber = defaultValue
+ };
+ }
+ return defaultValue;
+ }
+
+ throw;
+ }
+ }
+
+ public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var model = await GetModelInfo(info, cancellationToken).ConfigureAwait(false);
+
+ using (var stream = await _httpClient.Get(new HttpRequestOptions()
+ {
+ Url = string.Format("{0}/tuners.html", GetApiUrl(info, false)),
+ CancellationToken = cancellationToken,
+ TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(5).TotalMilliseconds),
+ BufferContent = false
+ }))
+ {
+ var tuners = new List<LiveTvTunerInfo>();
+ using (var sr = new StreamReader(stream, System.Text.Encoding.UTF8))
+ {
+ while (!sr.EndOfStream)
+ {
+ string line = StripXML(sr.ReadLine());
+ if (line.Contains("Channel"))
+ {
+ LiveTvTunerStatus status;
+ var index = line.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
+ var name = line.Substring(0, index - 1);
+ var currentChannel = line.Substring(index + 7);
+ if (currentChannel != "none") { status = LiveTvTunerStatus.LiveTv; } else { status = LiveTvTunerStatus.Available; }
+ tuners.Add(new LiveTvTunerInfo
+ {
+ Name = name,
+ SourceType = string.IsNullOrWhiteSpace(model) ? Name : model,
+ ProgramName = currentChannel,
+ Status = status
+ });
+ }
+ }
+ }
+ return tuners;
+ }
+ }
+
+ public async Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
+ {
+ var list = new List<LiveTvTunerInfo>();
+
+ foreach (var host in GetConfiguration().TunerHosts
+ .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)))
+ {
+ try
+ {
+ list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false));
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error getting tuner info", ex);
+ }
+ }
+
+ return list;
+ }
+
+ private string GetApiUrl(TunerHostInfo info, bool isPlayback)
+ {
+ var url = info.Url;
+
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ throw new ArgumentException("Invalid tuner info");
+ }
+
+ if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url;
+ }
+
+ var uri = new Uri(url);
+
+ if (isPlayback)
+ {
+ var builder = new UriBuilder(uri);
+ builder.Port = 5004;
+ uri = builder.Uri;
+ }
+
+ return uri.AbsoluteUri.TrimEnd('/');
+ }
+
+ private static string StripXML(string source)
+ {
+ char[] buffer = new char[source.Length];
+ int bufferIndex = 0;
+ bool inside = false;
+
+ for (int i = 0; i < source.Length; i++)
+ {
+ char let = source[i];
+ if (let == '<')
+ {
+ inside = true;
+ continue;
+ }
+ if (let == '>')
+ {
+ inside = false;
+ continue;
+ }
+ if (!inside)
+ {
+ buffer[bufferIndex] = let;
+ bufferIndex++;
+ }
+ }
+ return new string(buffer, 0, bufferIndex);
+ }
+
+ private class Channels
+ {
+ public string GuideNumber { get; set; }
+ public string GuideName { get; set; }
+ public string VideoCodec { get; set; }
+ public string AudioCodec { get; set; }
+ public string URL { get; set; }
+ public bool Favorite { get; set; }
+ public bool DRM { get; set; }
+ public int HD { get; set; }
+ }
+
+ private async Task<MediaSourceInfo> GetMediaSource(TunerHostInfo info, string channelId, string profile)
+ {
+ int? width = null;
+ int? height = null;
+ bool isInterlaced = true;
+ string videoCodec = null;
+ string audioCodec = "ac3";
+
+ int? videoBitrate = null;
+ int? audioBitrate = null;
+
+ if (string.Equals(profile, "mobile", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 1280;
+ height = 720;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2000000;
+ }
+ else if (string.Equals(profile, "heavy", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 1920;
+ height = 1080;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 15000000;
+ }
+ else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 960;
+ height = 546;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2500000;
+ }
+ else if (string.Equals(profile, "internet480", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 848;
+ height = 480;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2000000;
+ }
+ else if (string.Equals(profile, "internet360", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 640;
+ height = 360;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 1500000;
+ }
+ else if (string.Equals(profile, "internet240", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 432;
+ height = 240;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 1000000;
+ }
+
+ var channels = await GetChannels(info, true, CancellationToken.None).ConfigureAwait(false);
+ var channel = channels.FirstOrDefault(i => string.Equals(i.Number, channelId, StringComparison.OrdinalIgnoreCase));
+ if (channel != null)
+ {
+ if (string.IsNullOrWhiteSpace(videoCodec))
+ {
+ videoCodec = channel.VideoCodec;
+ }
+ audioCodec = channel.AudioCodec;
+
+ if (!videoBitrate.HasValue)
+ {
+ videoBitrate = (channel.IsHD ?? true) ? 15000000 : 2000000;
+ }
+ audioBitrate = (channel.IsHD ?? true) ? 448000 : 192000;
+ }
+
+ // normalize
+ if (string.Equals(videoCodec, "mpeg2", StringComparison.OrdinalIgnoreCase))
+ {
+ videoCodec = "mpeg2video";
+ }
+
+ string nal = null;
+ if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ nal = "0";
+ }
+
+ var url = GetApiUrl(info, true) + "/auto/v" + channelId;
+
+ // If raw was used, the tuner doesn't support params
+ if (!string.IsNullOrWhiteSpace(profile)
+ && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase))
+ {
+ url += "?transcode=" + profile;
+ }
+
+ var id = profile;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ id = "native";
+ }
+ id += "_" + url.GetMD5().ToString("N");
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Path = url,
+ Protocol = MediaProtocol.Http,
+ MediaStreams = new List<MediaStream>
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+ IsInterlaced = isInterlaced,
+ Codec = videoCodec,
+ Width = width,
+ Height = height,
+ BitRate = videoBitrate,
+ NalLengthSize = nal
+
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1,
+ Codec = audioCodec,
+ BitRate = audioBitrate
+ }
+ },
+ RequiresOpening = true,
+ RequiresClosing = false,
+ BufferMs = 0,
+ Container = "ts",
+ Id = id,
+ SupportsDirectPlay = false,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true
+ };
+
+ return mediaSource;
+ }
+
+ protected EncodingOptions GetEncodingOptions()
+ {
+ return Config.GetConfiguration<EncodingOptions>("encoding");
+ }
+
+ private string GetHdHrIdFromChannelId(string channelId)
+ {
+ return channelId.Split('_')[1];
+ }
+
+ protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, string channelId, CancellationToken cancellationToken)
+ {
+ var list = new List<MediaSourceInfo>();
+
+ if (!channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return list;
+ }
+ var hdhrId = GetHdHrIdFromChannelId(channelId);
+
+ try
+ {
+ var model = await GetModelInfo(info, cancellationToken).ConfigureAwait(false);
+ model = model ?? string.Empty;
+
+ if ((model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1))
+ {
+ list.Add(await GetMediaSource(info, hdhrId, "native").ConfigureAwait(false));
+
+ if (info.AllowHWTranscoding)
+ {
+ list.Add(await GetMediaSource(info, hdhrId, "heavy").ConfigureAwait(false));
+
+ list.Add(await GetMediaSource(info, hdhrId, "internet540").ConfigureAwait(false));
+ list.Add(await GetMediaSource(info, hdhrId, "internet480").ConfigureAwait(false));
+ list.Add(await GetMediaSource(info, hdhrId, "internet360").ConfigureAwait(false));
+ list.Add(await GetMediaSource(info, hdhrId, "internet240").ConfigureAwait(false));
+ list.Add(await GetMediaSource(info, hdhrId, "mobile").ConfigureAwait(false));
+ }
+ }
+ }
+ catch
+ {
+
+ }
+
+ if (list.Count == 0)
+ {
+ list.Add(await GetMediaSource(info, hdhrId, "native").ConfigureAwait(false));
+ }
+
+ return list;
+ }
+
+ protected override bool IsValidChannelId(string channelId)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ protected override async Task<LiveStream> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ var profile = streamId.Split('_')[0];
+
+ Logger.Info("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channelId, streamId, profile);
+
+ if (!channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("Channel not found");
+ }
+ var hdhrId = GetHdHrIdFromChannelId(channelId);
+
+ var mediaSource = await GetMediaSource(info, hdhrId, profile).ConfigureAwait(false);
+
+ var liveStream = new HdHomerunLiveStream(mediaSource, streamId, _fileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost);
+ liveStream.EnableStreamSharing = true;
+ return liveStream;
+ }
+
+ public async Task Validate(TunerHostInfo info)
+ {
+ if (!info.IsEnabled)
+ {
+ return;
+ }
+
+ lock (_modelCache)
+ {
+ _modelCache.Clear();
+ }
+
+ try
+ {
+ // Test it by pulling down the lineup
+ using (var stream = await _httpClient.Get(new HttpRequestOptions
+ {
+ Url = string.Format("{0}/discover.json", GetApiUrl(info, false)),
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ }))
+ {
+ var response = JsonSerializer.DeserializeFromStream<DiscoverResponse>(stream);
+
+ info.DeviceId = response.DeviceID;
+ }
+ }
+ catch (HttpException ex)
+ {
+ if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound)
+ {
+ // HDHR4 doesn't have this api
+ return;
+ }
+
+ throw;
+ }
+ }
+
+ protected override async Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken)
+ {
+ var info = await GetTunerInfos(tuner, cancellationToken).ConfigureAwait(false);
+
+ return info.Any(i => i.Status == LiveTvTunerStatus.Available);
+ }
+
+ public class DiscoverResponse
+ {
+ public string FriendlyName { get; set; }
+ public string ModelNumber { get; set; }
+ public string FirmwareName { get; set; }
+ public string FirmwareVersion { get; set; }
+ public string DeviceID { get; set; }
+ public string DeviceAuth { get; set; }
+ public string BaseURL { get; set; }
+ public string LineupURL { get; set; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs
new file mode 100644
index 000000000..4852270d5
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs
@@ -0,0 +1,144 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunLiveStream : LiveStream, IDirectStreamProvider
+ {
+ private readonly ILogger _logger;
+ private readonly IHttpClient _httpClient;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly IServerApplicationHost _appHost;
+
+ private readonly CancellationTokenSource _liveStreamCancellationTokenSource = new CancellationTokenSource();
+ private readonly TaskCompletionSource<bool> _liveStreamTaskCompletionSource = new TaskCompletionSource<bool>();
+ private readonly MulticastStream _multicastStream;
+
+
+ public HdHomerunLiveStream(MediaSourceInfo mediaSource, string originalStreamId, IFileSystem fileSystem, IHttpClient httpClient, ILogger logger, IServerApplicationPaths appPaths, IServerApplicationHost appHost)
+ : base(mediaSource)
+ {
+ _fileSystem = fileSystem;
+ _httpClient = httpClient;
+ _logger = logger;
+ _appPaths = appPaths;
+ _appHost = appHost;
+ OriginalStreamId = originalStreamId;
+ _multicastStream = new MulticastStream(_logger);
+ }
+
+ protected override async Task OpenInternal(CancellationToken openCancellationToken)
+ {
+ _liveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+ var mediaSource = OriginalMediaSource;
+
+ var url = mediaSource.Path;
+
+ _logger.Info("Opening HDHR Live stream from {0}", url);
+
+ var taskCompletionSource = new TaskCompletionSource<bool>();
+
+ StartStreaming(url, taskCompletionSource, _liveStreamCancellationTokenSource.Token);
+
+ //OpenedMediaSource.Protocol = MediaProtocol.File;
+ //OpenedMediaSource.Path = tempFile;
+ //OpenedMediaSource.ReadAtNativeFramerate = true;
+
+ OpenedMediaSource.Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts";
+ OpenedMediaSource.Protocol = MediaProtocol.Http;
+ OpenedMediaSource.SupportsDirectPlay = false;
+ OpenedMediaSource.SupportsDirectStream = true;
+ OpenedMediaSource.SupportsTranscoding = true;
+
+ await taskCompletionSource.Task.ConfigureAwait(false);
+
+ //await Task.Delay(5000).ConfigureAwait(false);
+ }
+
+ public override Task Close()
+ {
+ _logger.Info("Closing HDHR live stream");
+ _liveStreamCancellationTokenSource.Cancel();
+
+ return _liveStreamTaskCompletionSource.Task;
+ }
+
+ private async Task StartStreaming(string url, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+ {
+ await Task.Run(async () =>
+ {
+ var isFirstAttempt = true;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ using (var response = await _httpClient.SendAsync(new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = cancellationToken,
+ BufferContent = false,
+
+ // Increase a little bit
+ TimeoutMs = 30000
+
+ }, "GET").ConfigureAwait(false))
+ {
+ _logger.Info("Opened HDHR stream from {0}", url);
+
+ if (!cancellationToken.IsCancellationRequested)
+ {
+ _logger.Info("Beginning multicastStream.CopyUntilCancelled");
+
+ Action onStarted = null;
+ if (isFirstAttempt)
+ {
+ onStarted = () => openTaskCompletionSource.TrySetResult(true);
+ }
+
+ await _multicastStream.CopyUntilCancelled(response.Content, onStarted, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ if (isFirstAttempt)
+ {
+ _logger.ErrorException("Error opening live stream:", ex);
+ openTaskCompletionSource.TrySetException(ex);
+ break;
+ }
+
+ _logger.ErrorException("Error copying live stream, will reopen", ex);
+ }
+
+ isFirstAttempt = false;
+ }
+
+ _liveStreamTaskCompletionSource.TrySetResult(true);
+
+ }).ConfigureAwait(false);
+ }
+
+ public Task CopyToAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ return _multicastStream.CopyToAsync(stream);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs
new file mode 100644
index 000000000..8027ce2dd
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs
@@ -0,0 +1,171 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Serialization;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts
+{
+ public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
+ {
+ private readonly IFileSystem _fileSystem;
+ private readonly IHttpClient _httpClient;
+ private readonly IServerApplicationHost _appHost;
+
+ public M3UTunerHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IHttpClient httpClient, IServerApplicationHost appHost)
+ : base(config, logger, jsonSerializer, mediaEncoder)
+ {
+ _fileSystem = fileSystem;
+ _httpClient = httpClient;
+ _appHost = appHost;
+ }
+
+ public override string Type
+ {
+ get { return "m3u"; }
+ }
+
+ public string Name
+ {
+ get { return "M3U Tuner"; }
+ }
+
+ private const string ChannelIdPrefix = "m3u_";
+
+ protected override async Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ return await new M3uParser(Logger, _fileSystem, _httpClient, _appHost).Parse(info.Url, ChannelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
+ {
+ var list = GetTunerHosts()
+ .Select(i => new LiveTvTunerInfo()
+ {
+ Name = Name,
+ SourceType = Type,
+ Status = LiveTvTunerStatus.Available,
+ Id = i.Url.GetMD5().ToString("N"),
+ Url = i.Url
+ })
+ .ToList();
+
+ return Task.FromResult(list);
+ }
+
+ protected override async Task<LiveStream> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ var sources = await GetChannelStreamMediaSources(info, channelId, cancellationToken).ConfigureAwait(false);
+
+ var liveStream = new LiveStream(sources.First());
+ return liveStream;
+ }
+
+ public async Task Validate(TunerHostInfo info)
+ {
+ using (var stream = await new M3uParser(Logger, _fileSystem, _httpClient, _appHost).GetListingsStream(info.Url, CancellationToken.None).ConfigureAwait(false))
+ {
+
+ }
+ }
+
+ protected override bool IsValidChannelId(string channelId)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException("channelId");
+ }
+
+ return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, string channelId, CancellationToken cancellationToken)
+ {
+ var urlHash = info.Url.GetMD5().ToString("N");
+ var prefix = ChannelIdPrefix + urlHash;
+ if (!channelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false);
+ var m3uchannels = channels.Cast<M3UChannel>();
+ var channel = m3uchannels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase));
+ if (channel != null)
+ {
+ var path = channel.Path;
+ MediaProtocol protocol = MediaProtocol.File;
+ if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ protocol = MediaProtocol.Http;
+ }
+ else if (path.StartsWith("rtmp", StringComparison.OrdinalIgnoreCase))
+ {
+ protocol = MediaProtocol.Rtmp;
+ }
+ else if (path.StartsWith("rtsp", StringComparison.OrdinalIgnoreCase))
+ {
+ protocol = MediaProtocol.Rtsp;
+ }
+ else if (path.StartsWith("udp", StringComparison.OrdinalIgnoreCase))
+ {
+ protocol = MediaProtocol.Udp;
+ }
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Path = channel.Path,
+ Protocol = protocol,
+ MediaStreams = new List<MediaStream>
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+ IsInterlaced = true
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+
+ }
+ },
+ RequiresOpening = true,
+ RequiresClosing = true,
+
+ ReadAtNativeFramerate = false,
+
+ Id = channel.Path.GetMD5().ToString("N"),
+ IsInfiniteStream = true
+ };
+
+ return new List<MediaSourceInfo> { mediaSource };
+ }
+ return new List<MediaSourceInfo>();
+ }
+
+ protected override Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
new file mode 100644
index 000000000..e0f040281
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
@@ -0,0 +1,276 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts
+{
+ public class M3uParser
+ {
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IHttpClient _httpClient;
+ private readonly IServerApplicationHost _appHost;
+
+ public M3uParser(ILogger logger, IFileSystem fileSystem, IHttpClient httpClient, IServerApplicationHost appHost)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _httpClient = httpClient;
+ _appHost = appHost;
+ }
+
+ public async Task<List<M3UChannel>> Parse(string url, string channelIdPrefix, string tunerHostId, CancellationToken cancellationToken)
+ {
+ var urlHash = url.GetMD5().ToString("N");
+
+ // Read the file and display it line by line.
+ using (var reader = new StreamReader(await GetListingsStream(url, cancellationToken).ConfigureAwait(false)))
+ {
+ return GetChannels(reader, urlHash, channelIdPrefix, tunerHostId);
+ }
+ }
+
+ public Task<Stream> GetListingsStream(string url, CancellationToken cancellationToken)
+ {
+ if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return _httpClient.Get(new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = cancellationToken,
+ // Some data providers will require a user agent
+ UserAgent = _appHost.FriendlyName + "/" + _appHost.ApplicationVersion
+ });
+ }
+ return Task.FromResult(_fileSystem.OpenRead(url));
+ }
+
+ const string ExtInfPrefix = "#EXTINF:";
+ private List<M3UChannel> GetChannels(StreamReader reader, string urlHash, string channelIdPrefix, string tunerHostId)
+ {
+ var channels = new List<M3UChannel>();
+ string line;
+ string extInf = "";
+ while ((line = reader.ReadLine()) != null)
+ {
+ line = line.Trim();
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (line.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ extInf = line.Substring(ExtInfPrefix.Length).Trim();
+ _logger.Info("Found m3u channel: {0}", extInf);
+ }
+ else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase))
+ {
+ var channel = GetChannelnfo(extInf, tunerHostId, line);
+ channel.Id = channelIdPrefix + urlHash + line.GetMD5().ToString("N");
+ channel.Path = line;
+ channels.Add(channel);
+ extInf = "";
+ }
+ }
+ return channels;
+ }
+
+ private M3UChannel GetChannelnfo(string extInf, string tunerHostId, string mediaUrl)
+ {
+ var channel = new M3UChannel();
+ channel.TunerHostId = tunerHostId;
+
+ extInf = extInf.Trim();
+
+ string remaining;
+ var attributes = ParseExtInf(extInf, out remaining);
+ extInf = remaining;
+
+ string value;
+ if (attributes.TryGetValue("tvg-logo", out value))
+ {
+ channel.ImageUrl = value;
+ }
+
+ channel.Name = GetChannelName(extInf, attributes);
+ channel.Number = GetChannelNumber(extInf, attributes, mediaUrl);
+
+ return channel;
+ }
+
+ private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
+ {
+ var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+ var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null;
+
+ var numberString = nameParts[0];
+
+ //Check for channel number with the format from SatIp
+ int number;
+ if (!string.IsNullOrWhiteSpace(nameInExtInf))
+ {
+ var numberIndex = nameInExtInf.IndexOf('.');
+ if (numberIndex > 0)
+ {
+ if (int.TryParse(nameInExtInf.Substring(0, numberIndex), out number))
+ {
+ numberString = number.ToString();
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(numberString))
+ {
+ numberString = numberString.Trim();
+ }
+
+ if (string.IsNullOrWhiteSpace(numberString) ||
+ string.Equals(numberString, "-1", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(numberString, "0", StringComparison.OrdinalIgnoreCase))
+ {
+ string value;
+ if (attributes.TryGetValue("tvg-id", out value))
+ {
+ numberString = value;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(numberString))
+ {
+ numberString = numberString.Trim();
+ }
+
+ if (string.IsNullOrWhiteSpace(numberString) ||
+ string.Equals(numberString, "-1", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(numberString, "0", StringComparison.OrdinalIgnoreCase))
+ {
+ string value;
+ if (attributes.TryGetValue("channel-id", out value))
+ {
+ numberString = value;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(numberString))
+ {
+ numberString = numberString.Trim();
+ }
+
+ if (string.IsNullOrWhiteSpace(numberString) ||
+ string.Equals(numberString, "-1", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(numberString, "0", StringComparison.OrdinalIgnoreCase))
+ {
+ numberString = null;
+ }
+
+ if (string.IsNullOrWhiteSpace(numberString))
+ {
+ if (string.IsNullOrWhiteSpace(mediaUrl))
+ {
+ numberString = null;
+ }
+ else
+ {
+ numberString = Path.GetFileNameWithoutExtension(mediaUrl.Split('/').Last());
+
+ double value;
+ if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out value))
+ {
+ numberString = null;
+ }
+ }
+ }
+
+ return numberString;
+ }
+
+ private string GetChannelName(string extInf, Dictionary<string, string> attributes)
+ {
+ var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+ var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null;
+
+ //Check for channel number with the format from SatIp
+ int number;
+ if (!string.IsNullOrWhiteSpace(nameInExtInf))
+ {
+ var numberIndex = nameInExtInf.IndexOf('.');
+ if (numberIndex > 0)
+ {
+ if (int.TryParse(nameInExtInf.Substring(0, numberIndex), out number))
+ {
+ //channel.Number = number.ToString();
+ nameInExtInf = nameInExtInf.Substring(numberIndex + 1);
+ }
+ }
+ }
+
+ string name;
+ attributes.TryGetValue("tvg-name", out name);
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ name = nameInExtInf;
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ attributes.TryGetValue("tvg-id", out name);
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ name = null;
+ }
+
+ return name;
+ }
+
+ private Dictionary<string, string> ParseExtInf(string line, out string remaining)
+ {
+ var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ var reg = new Regex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase);
+ var matches = reg.Matches(line);
+ var minIndex = int.MaxValue;
+ foreach (Match match in matches)
+ {
+ dict[match.Groups[1].Value] = match.Groups[2].Value;
+ minIndex = Math.Min(minIndex, match.Index);
+ }
+
+ if (minIndex > 0 && minIndex < line.Length)
+ {
+ line = line.Substring(0, minIndex);
+ }
+
+ remaining = line;
+
+ return dict;
+ }
+ }
+
+
+ public class M3UChannel : ChannelInfo
+ {
+ public string Path { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs
new file mode 100644
index 000000000..7b88be19c
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts
+{
+ public class MulticastStream
+ {
+ private readonly List<QueueStream> _outputStreams = new List<QueueStream>();
+ private const int BufferSize = 81920;
+ private CancellationToken _cancellationToken;
+ private readonly ILogger _logger;
+
+ public MulticastStream(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task CopyUntilCancelled(Stream source, Action onStarted, CancellationToken cancellationToken)
+ {
+ _cancellationToken = cancellationToken;
+
+ byte[] buffer = new byte[BufferSize];
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
+
+ if (bytesRead > 0)
+ {
+ byte[] copy = new byte[bytesRead];
+ Buffer.BlockCopy(buffer, 0, copy, 0, bytesRead);
+
+ List<QueueStream> streams = null;
+
+ lock (_outputStreams)
+ {
+ streams = _outputStreams.ToList();
+ }
+
+ foreach (var stream in streams)
+ {
+ stream.Queue(copy);
+ }
+
+ if (onStarted != null)
+ {
+ var onStartedCopy = onStarted;
+ onStarted = null;
+ Task.Run(onStartedCopy);
+ }
+ }
+
+ else
+ {
+ await Task.Delay(100).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public Task CopyToAsync(Stream stream)
+ {
+ var result = new QueueStream(stream, _logger)
+ {
+ OnFinished = OnFinished
+ };
+
+ lock (_outputStreams)
+ {
+ _outputStreams.Add(result);
+ }
+
+ result.Start(_cancellationToken);
+
+ return result.TaskCompletion.Task;
+ }
+
+ public void RemoveOutputStream(QueueStream stream)
+ {
+ lock (_outputStreams)
+ {
+ _outputStreams.Remove(stream);
+ }
+ }
+
+ private void OnFinished(QueueStream queueStream)
+ {
+ RemoveOutputStream(queueStream);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs
new file mode 100644
index 000000000..bd6f31906
--- /dev/null
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Logging;
+
+namespace Emby.Server.Implementations.LiveTv.TunerHosts
+{
+ public class QueueStream
+ {
+ private readonly Stream _outputStream;
+ private readonly ConcurrentQueue<byte[]> _queue = new ConcurrentQueue<byte[]>();
+ private CancellationToken _cancellationToken;
+ public TaskCompletionSource<bool> TaskCompletion { get; private set; }
+
+ public Action<QueueStream> OnFinished { get; set; }
+ private readonly ILogger _logger;
+ private bool _isActive;
+
+ public QueueStream(Stream outputStream, ILogger logger)
+ {
+ _outputStream = outputStream;
+ _logger = logger;
+ TaskCompletion = new TaskCompletionSource<bool>();
+ }
+
+ public void Queue(byte[] bytes)
+ {
+ if (_isActive)
+ {
+ _queue.Enqueue(bytes);
+ }
+ }
+
+ public void Start(CancellationToken cancellationToken)
+ {
+ _cancellationToken = cancellationToken;
+ Task.Run(() => StartInternal());
+ }
+
+ private byte[] Dequeue()
+ {
+ byte[] bytes;
+ if (_queue.TryDequeue(out bytes))
+ {
+ return bytes;
+ }
+
+ return null;
+ }
+
+ private async Task StartInternal()
+ {
+ var cancellationToken = _cancellationToken;
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ _isActive = true;
+
+ var bytes = Dequeue();
+ if (bytes != null)
+ {
+ await _outputStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ TaskCompletion.TrySetResult(true);
+ _logger.Debug("QueueStream complete");
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.Debug("QueueStream cancelled");
+ TaskCompletion.TrySetCanceled();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in QueueStream", ex);
+ TaskCompletion.TrySetException(ex);
+ }
+ finally
+ {
+ _isActive = false;
+
+ if (OnFinished != null)
+ {
+ OnFinished(this);
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
new file mode 100644
index 000000000..28977c4f9
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u062e\u0631\u0648\u062c",
+ "LabelVisitCommunity": "\u0632\u064a\u0627\u0631\u0629 \u0627\u0644\u0645\u062c\u062a\u0645\u0639",
+ "LabelGithub": "\u062c\u064a\u062a \u0647\u0628",
+ "LabelApiDocumentation": "\u0645\u0639\u0644\u0648\u0645\u0627\u062a \u0645\u062f\u062e\u0644 \u0628\u0631\u0645\u062c\u0629 \u0627\u0644\u062a\u0637\u0628\u064a\u0642",
+ "LabelDeveloperResources": "\u0645\u0643\u062a\u0628\u0629 \u0627\u0644\u0645\u0628\u0631\u0645\u062c",
+ "LabelBrowseLibrary": "\u062a\u0635\u0641\u062d \u0627\u0644\u0645\u0643\u062a\u0628\u0629",
+ "LabelConfigureServer": "\u0625\u0639\u062f\u0627\u062f \u0625\u0645\u0628\u064a",
+ "LabelRestartServer": "\u0627\u0639\u0627\u062f\u0629 \u062a\u0634\u063a\u064a\u0644 \u0627\u0644\u062e\u0627\u062f\u0645",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json
new file mode 100644
index 000000000..22b99408d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\u0421\u043c\u0435\u0441\u0435\u043d\u043e \u0441\u044a\u0434\u044a\u0440\u0436\u0430\u043d\u0438\u0435",
+ "FolderTypeMovies": "\u0424\u0438\u043b\u043c\u0438",
+ "FolderTypeMusic": "\u041c\u0443\u0437\u0438\u043a\u0430",
+ "FolderTypeAdultVideos": "\u041a\u043b\u0438\u043f\u043e\u0432\u0435 \u0437\u0430 \u0432\u044a\u0437\u0440\u0430\u0441\u0442\u043d\u0438",
+ "FolderTypePhotos": "\u0421\u043d\u0438\u043c\u043a\u0438",
+ "FolderTypeMusicVideos": "\u041c\u0443\u0437\u0438\u043a\u0430\u043b\u043d\u0438 \u043a\u043b\u0438\u043f\u043e\u0432\u0435",
+ "FolderTypeHomeVideos": "\u0414\u043e\u043c\u0430\u0448\u043d\u0438 \u043a\u043b\u0438\u043f\u043e\u0432\u0435",
+ "FolderTypeGames": "\u0418\u0433\u0440\u0438",
+ "FolderTypeBooks": "\u041a\u043d\u0438\u0433\u0438",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "\u041d\u0430\u0441\u043b\u0435\u0434\u0438",
+ "HeaderCastCrew": "\u0415\u043a\u0438\u043f",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u0418\u0437\u0445\u043e\u0434",
+ "LabelVisitCommunity": "\u041f\u043e\u0441\u0435\u0442\u0438 \u043e\u0431\u0449\u0435\u0441\u0442\u0432\u043e\u0442\u043e",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "API \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f",
+ "LabelDeveloperResources": "\u0420\u0435\u0441\u0443\u0440\u0441\u0438 \u0437\u0430 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u0446\u0438",
+ "LabelBrowseLibrary": "\u0420\u0430\u0437\u0433\u043b\u0435\u0434\u0430\u0439 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0442\u0430",
+ "LabelConfigureServer": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439 Emby",
+ "LabelRestartServer": "\u0420\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439 \u0441\u044a\u0440\u0432\u044a\u0440\u0430",
+ "CategorySync": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437.",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "\u0412\u0435\u0440\u0441\u0438\u044f {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby \u0441\u044a\u0440\u0432\u044a\u0440\u044a\u0442 \u0431\u0435 \u043e\u0431\u043d\u043e\u0432\u0435\u043d.",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "\u0410\u0443\u0434\u0438\u043e",
+ "HeaderVideo": "\u0412\u0438\u0434\u0435\u043e",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "\u0421\u044a\u0441\u0442\u043e\u044f\u043d\u0438\u0435:",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json
new file mode 100644
index 000000000..7ca8e1553
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ca.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Si et plau espera mentre la teva base de dades del Servidor Emby \u00e9s actualitzada. {0}% completat.",
+ "AppDeviceValues": "App: {0}, Dispositiu: {1}",
+ "UserDownloadingItemWithValues": "{0} est\u00e0 descarregant {1}",
+ "FolderTypeMixed": "Contingut barrejat",
+ "FolderTypeMovies": "Pel\u00b7l\u00edcules",
+ "FolderTypeMusic": "M\u00fasica",
+ "FolderTypeAdultVideos": "V\u00eddeos per adults",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "V\u00eddeos musicals",
+ "FolderTypeHomeVideos": "V\u00eddeos dom\u00e8stics",
+ "FolderTypeGames": "Jocs",
+ "FolderTypeBooks": "Llibres",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Heretat",
+ "HeaderCastCrew": "Repartiment i Equip",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Cap\u00edtol {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "LabelExit": "Sortir",
+ "LabelVisitCommunity": "Visita la Comunitat",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentaci\u00f3 de l'API",
+ "LabelDeveloperResources": "Recursos per a Desenvolupadors",
+ "LabelBrowseLibrary": "Examina la Biblioteca",
+ "LabelConfigureServer": "Configura Emby",
+ "LabelRestartServer": "Reiniciar Servidor",
+ "CategorySync": "Sync",
+ "CategoryUser": "Usuari",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Aplicaci\u00f3",
+ "CategoryPlugin": "Complement",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Actualitzaci\u00f3 d'aplicaci\u00f3 disponible",
+ "NotificationOptionApplicationUpdateInstalled": "Actualitzaci\u00f3 d'aplicaci\u00f3 instal\u00b7lada",
+ "NotificationOptionPluginUpdateInstalled": "Actualitzaci\u00f3 de complement instal\u00b7lada",
+ "NotificationOptionPluginInstalled": "Complement instal\u00b7lat",
+ "NotificationOptionPluginUninstalled": "Complement desinstal\u00b7lat",
+ "NotificationOptionVideoPlayback": "Reproducci\u00f3 de v\u00eddeo iniciada",
+ "NotificationOptionAudioPlayback": "Reproducci\u00f3 d'\u00e0udio iniciada",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Reproducci\u00f3 de v\u00eddeo aturada",
+ "NotificationOptionAudioPlaybackStopped": "Reproducci\u00f3 d'\u00e0udio aturada",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Tasca programada fallida",
+ "NotificationOptionInstallationFailed": "Instal\u00b7laci\u00f3 fallida",
+ "NotificationOptionNewLibraryContent": "Nou contingut afegit",
+ "NotificationOptionNewLibraryContentMultiple": "Nous continguts afegits",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "Usuari blocat",
+ "NotificationOptionServerRestartRequired": "Cal reiniciar el servidor",
+ "ViewTypePlaylists": "Llistes de reproducci\u00f3",
+ "ViewTypeMovies": "Pel\u00b7l\u00edcules",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Jocs",
+ "ViewTypeMusic": "M\u00fasica",
+ "ViewTypeMusicGenres": "G\u00e8neres",
+ "ViewTypeMusicArtists": "Artistes",
+ "ViewTypeBoxSets": "Col\u00b7leccions",
+ "ViewTypeChannels": "Canals",
+ "ViewTypeLiveTV": "TV en Directe",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Darrers Jocs",
+ "ViewTypeRecentlyPlayedGames": "Reprodu\u00eft Recentment",
+ "ViewTypeGameFavorites": "Preferits",
+ "ViewTypeGameSystems": "Sistemes de Jocs",
+ "ViewTypeGameGenres": "G\u00e8neres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "A Continuaci\u00f3",
+ "ViewTypeTvLatest": "Darrers",
+ "ViewTypeTvShowSeries": "S\u00e8ries:",
+ "ViewTypeTvGenres": "G\u00e8neres",
+ "ViewTypeTvFavoriteSeries": "S\u00e8ries Preferides",
+ "ViewTypeTvFavoriteEpisodes": "Episodis Preferits",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Darrers",
+ "ViewTypeMovieMovies": "Pel\u00b7l\u00edcules",
+ "ViewTypeMovieCollections": "Col\u00b7leccions",
+ "ViewTypeMovieFavorites": "Preferides",
+ "ViewTypeMovieGenres": "G\u00e8neres",
+ "ViewTypeMusicLatest": "Novetats",
+ "ViewTypeMusicPlaylists": "Llistes de reproducci\u00f3",
+ "ViewTypeMusicAlbums": "\u00c0lbums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Prefer\u00e8ncies de Visualitzaci\u00f3",
+ "ViewTypeMusicSongs": "Can\u00e7ons",
+ "ViewTypeMusicFavorites": "Preferides",
+ "ViewTypeMusicFavoriteAlbums": "\u00c0lbums Preferits",
+ "ViewTypeMusicFavoriteArtists": "Artistes Preferits",
+ "ViewTypeMusicFavoriteSongs": "Can\u00e7ons Preferides",
+ "ViewTypeFolders": "Directoris",
+ "ViewTypeLiveTvRecordingGroups": "Enregistraments",
+ "ViewTypeLiveTvChannels": "Canals",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Versi\u00f3 {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} afegit a la biblioteca",
+ "ItemRemovedWithName": "{0} eliminat de la biblioteca",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Prove\u00efdor: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "L'usuari {0} ha estat eliminat",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} autenticat correctament",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "L'usuari {0} ha estat blocat",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} ha comen\u00e7at a reproduir {1}",
+ "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Usuari",
+ "HeaderName": "Nom",
+ "HeaderDate": "Data",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Data afegida",
+ "HeaderReleaseDate": "Data de publicaci\u00f3",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Temporada",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "S\u00e8ries:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Any:",
+ "HeaderYears": "Anys:",
+ "HeaderParentalRating": "Valoraci\u00f3 Parental",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Tr\u00e0ilers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Sistemes de Jocs",
+ "HeaderPlayers": "Jugadors:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "\u00c0udio",
+ "HeaderVideo": "V\u00eddeo",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subt\u00edtols",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Estat",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "M\u00fasic",
+ "HeaderLocked": "Blocat",
+ "HeaderStudios": "Estudis",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Compositors",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Artista convidat",
+ "HeaderProducer": "Productors",
+ "HeaderWriter": "Escriptors",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Qualificacions de la comunitat",
+ "StartupEmbyServerIsLoading": "El servidor d'Emby s'est&agrave; carregant. Si et plau, tornau-ho a provar de nou en breu."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/core.json b/Emby.Server.Implementations/Localization/Core/core.json
new file mode 100644
index 000000000..976faa8cb
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/core.json
@@ -0,0 +1,179 @@
+{
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Exit",
+ "LabelVisitCommunity": "Visit Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Browse Library",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Restart Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly.",
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete."
+}
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
new file mode 100644
index 000000000..e3055f5ba
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Po\u010dkejte pros\u00edm, datab\u00e1ze Emby Serveru je aktualizov\u00e1na na novou verzi. Hotovo {0}%.",
+ "AppDeviceValues": "Aplikace: {0}, Za\u0159\u00edzen\u00ed: {1}",
+ "UserDownloadingItemWithValues": "{0} pr\u00e1v\u011b stahuje {1}",
+ "FolderTypeMixed": "Sm\u00ed\u0161en\u00fd obsah",
+ "FolderTypeMovies": "Filmy",
+ "FolderTypeMusic": "Hudba",
+ "FolderTypeAdultVideos": "Filmy pro dosp\u011bl\u00e9",
+ "FolderTypePhotos": "Fotky",
+ "FolderTypeMusicVideos": "Hudebn\u00ed klipy",
+ "FolderTypeHomeVideos": "Dom\u00e1c\u00ed video",
+ "FolderTypeGames": "Hry",
+ "FolderTypeBooks": "Knihy",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Zd\u011bdit",
+ "HeaderCastCrew": "Herci a obsazen\u00ed",
+ "HeaderPeople": "Lid\u00e9",
+ "ValueSpecialEpisodeName": "Speci\u00e1l - {0}",
+ "LabelChapterName": "Kapitola {0}",
+ "NameSeasonNumber": "Sez\u00f3na {0}",
+ "LabelExit": "Zav\u0159\u00edt",
+ "LabelVisitCommunity": "Nav\u0161t\u00edvit komunitu",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Dokumentace API",
+ "LabelDeveloperResources": "Zdroje v\u00fdvoj\u00e1\u0159\u016f",
+ "LabelBrowseLibrary": "Proch\u00e1zet knihovnu",
+ "LabelConfigureServer": "Konfigurovat Emby",
+ "LabelRestartServer": "Restartovat server",
+ "CategorySync": "Synchronizace",
+ "CategoryUser": "U\u017eivatel:",
+ "CategorySystem": "Syst\u00e9m",
+ "CategoryApplication": "Aplikace",
+ "CategoryPlugin": "Z\u00e1suvn\u00fd modul",
+ "NotificationOptionPluginError": "Chyba z\u00e1suvn\u00e9ho modulu",
+ "NotificationOptionApplicationUpdateAvailable": "Dostupnost aktualizace aplikace",
+ "NotificationOptionApplicationUpdateInstalled": "Instalace aktualizace aplikace",
+ "NotificationOptionPluginUpdateInstalled": "Aktualizace z\u00e1suvn\u00e9ho modulu instalov\u00e1na",
+ "NotificationOptionPluginInstalled": "Z\u00e1suvn\u00fd modul instalov\u00e1n",
+ "NotificationOptionPluginUninstalled": "Z\u00e1suvn\u00fd modul odstran\u011bn",
+ "NotificationOptionVideoPlayback": "P\u0159ehr\u00e1v\u00e1n\u00ed videa zah\u00e1jeno",
+ "NotificationOptionAudioPlayback": "P\u0159ehr\u00e1v\u00e1n\u00ed audia zah\u00e1jeno",
+ "NotificationOptionGamePlayback": "Spu\u0161t\u011bn\u00ed hry zah\u00e1jeno",
+ "NotificationOptionVideoPlaybackStopped": "P\u0159ehr\u00e1v\u00e1n\u00ed videa ukon\u010deno",
+ "NotificationOptionAudioPlaybackStopped": "P\u0159ehr\u00e1v\u00e1n\u00ed audia ukon\u010deno",
+ "NotificationOptionGamePlaybackStopped": "Hra ukon\u010dena",
+ "NotificationOptionTaskFailed": "Chyba napl\u00e1novan\u00e9 \u00falohy",
+ "NotificationOptionInstallationFailed": "Chyba instalace",
+ "NotificationOptionNewLibraryContent": "P\u0159id\u00e1n nov\u00fd obsah",
+ "NotificationOptionNewLibraryContentMultiple": "P\u0159id\u00e1n nov\u00fd obsah (v\u00edcen\u00e1sobn\u00fd)",
+ "NotificationOptionCameraImageUploaded": "Kamerov\u00fd z\u00e1znam nahr\u00e1n",
+ "NotificationOptionUserLockedOut": "U\u017eivatel uzam\u010den",
+ "NotificationOptionServerRestartRequired": "Je vy\u017eadov\u00e1n restart serveru",
+ "ViewTypePlaylists": "Playlisty",
+ "ViewTypeMovies": "Filmy",
+ "ViewTypeTvShows": "Televize",
+ "ViewTypeGames": "Hry",
+ "ViewTypeMusic": "Hudba",
+ "ViewTypeMusicGenres": "\u017d\u00e1nry",
+ "ViewTypeMusicArtists": "\u00dam\u011blci",
+ "ViewTypeBoxSets": "Kolekce",
+ "ViewTypeChannels": "Kan\u00e1ly",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Vys\u00edl\u00e1no nyn\u00ed",
+ "ViewTypeLatestGames": "Nejnov\u011bj\u0161\u00ed hry",
+ "ViewTypeRecentlyPlayedGames": "Ned\u00e1vno p\u0159ehr\u00e1no",
+ "ViewTypeGameFavorites": "Obl\u00edben\u00e9",
+ "ViewTypeGameSystems": "Syst\u00e9my hry",
+ "ViewTypeGameGenres": "\u017d\u00e1nry",
+ "ViewTypeTvResume": "Obnovit",
+ "ViewTypeTvNextUp": "O\u010dek\u00e1van\u00e9",
+ "ViewTypeTvLatest": "Nejnov\u011bj\u0161\u00ed",
+ "ViewTypeTvShowSeries": "Seri\u00e1l",
+ "ViewTypeTvGenres": "\u017d\u00e1nry",
+ "ViewTypeTvFavoriteSeries": "Obl\u00edben\u00e9 seri\u00e1ly",
+ "ViewTypeTvFavoriteEpisodes": "Obl\u00edben\u00e9 epizody",
+ "ViewTypeMovieResume": "Obnovit",
+ "ViewTypeMovieLatest": "Nejnov\u011bj\u0161\u00ed",
+ "ViewTypeMovieMovies": "Filmy",
+ "ViewTypeMovieCollections": "Kolekce",
+ "ViewTypeMovieFavorites": "Obl\u00edben\u00e9",
+ "ViewTypeMovieGenres": "\u017d\u00e1nry",
+ "ViewTypeMusicLatest": "Nejnov\u011bj\u0161\u00ed",
+ "ViewTypeMusicPlaylists": "Playlisty",
+ "ViewTypeMusicAlbums": "Alba",
+ "ViewTypeMusicAlbumArtists": "Alba \u00fam\u011blc\u016f",
+ "HeaderOtherDisplaySettings": "Nastaven\u00ed zobrazen\u00ed",
+ "ViewTypeMusicSongs": "Songy",
+ "ViewTypeMusicFavorites": "Obl\u00edben\u00e9",
+ "ViewTypeMusicFavoriteAlbums": "Obl\u00edben\u00e1 alba",
+ "ViewTypeMusicFavoriteArtists": "Obl\u00edben\u00ed \u00fam\u011blci",
+ "ViewTypeMusicFavoriteSongs": "Obl\u00edben\u00e9 songy",
+ "ViewTypeFolders": "Slo\u017eky",
+ "ViewTypeLiveTvRecordingGroups": "Nahr\u00e1vky",
+ "ViewTypeLiveTvChannels": "Kan\u00e1ly",
+ "ScheduledTaskFailedWithName": "{0} selhalo",
+ "LabelRunningTimeValue": "D\u00e9lka m\u00e9dia: {0}",
+ "ScheduledTaskStartedWithName": "{0} zah\u00e1jeno",
+ "VersionNumber": "Verze {0}",
+ "PluginInstalledWithName": "{0} byl nainstalov\u00e1n",
+ "PluginUpdatedWithName": "{0} byl aktualizov\u00e1n",
+ "PluginUninstalledWithName": "{0} byl odinstalov\u00e1n",
+ "ItemAddedWithName": "{0} byl p\u0159id\u00e1n do knihovny",
+ "ItemRemovedWithName": "{0} byl odstran\u011bn z knihovny",
+ "LabelIpAddressValue": "IP adresa: {0}",
+ "DeviceOnlineWithName": "{0} je p\u0159ipojen",
+ "UserOnlineFromDevice": "{0} se p\u0159ipojil z {1}",
+ "ProviderValue": "Poskytl: {0}",
+ "SubtitlesDownloadedForItem": "Sta\u017eeny titulky pro {0}",
+ "UserConfigurationUpdatedWithName": "Konfigurace u\u017eivatele byla aktualizov\u00e1na pro {0}",
+ "UserCreatedWithName": "U\u017eivatel {0} byl vytvo\u0159en",
+ "UserPasswordChangedWithName": "Pro u\u017eivatele {0} byla provedena zm\u011bna hesla",
+ "UserDeletedWithName": "U\u017eivatel {0} byl smaz\u00e1n",
+ "MessageServerConfigurationUpdated": "Konfigurace serveru byla aktualizov\u00e1na",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurace sekce {0} na serveru byla aktualizov\u00e1na",
+ "MessageApplicationUpdated": "Emby Server byl aktualizov\u00e1n",
+ "FailedLoginAttemptWithUserName": "Ne\u00fasp\u011b\u0161n\u00fd pokus o p\u0159ihl\u00e1\u0161en\u00ed z {0}",
+ "AuthenticationSucceededWithUserName": "{0} \u00fasp\u011b\u0161n\u011b ov\u011b\u0159en",
+ "DeviceOfflineWithName": "{0} se odpojil",
+ "UserLockedOutWithName": "U\u017eivatel {0} byl odem\u010den",
+ "UserOfflineFromDevice": "{0} se odpojil od {1}",
+ "UserStartedPlayingItemWithValues": "{0} spustil p\u0159ehr\u00e1v\u00e1n\u00ed {1}",
+ "UserStoppedPlayingItemWithValues": "{0} zastavil p\u0159ehr\u00e1v\u00e1n\u00ed {1}",
+ "SubtitleDownloadFailureForItem": "Stahov\u00e1n\u00ed titulk\u016f selhalo pro {0}",
+ "HeaderUnidentified": "Neidentifikov\u00e1n",
+ "HeaderImagePrimary": "Prim\u00e1rn\u00ed",
+ "HeaderImageBackdrop": "Pozad\u00ed",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Avatar u\u017eivatele",
+ "HeaderOverview": "P\u0159ehled",
+ "HeaderShortOverview": "Stru\u010dn\u00fd p\u0159ehled",
+ "HeaderType": "Typ",
+ "HeaderSeverity": "Z\u00e1va\u017enost",
+ "HeaderUser": "U\u017eivatel",
+ "HeaderName": "N\u00e1zev",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premi\u00e9ra",
+ "HeaderDateAdded": "P\u0159id\u00e1no",
+ "HeaderReleaseDate": "Datum vyd\u00e1n\u00ed",
+ "HeaderRuntime": "D\u00e9lka",
+ "HeaderPlayCount": "P\u0159ehr\u00e1no (po\u010det)",
+ "HeaderSeason": "Sez\u00f3na",
+ "HeaderSeasonNumber": "\u010c\u00edslo sez\u00f3ny",
+ "HeaderSeries": "Seri\u00e1l:",
+ "HeaderNetwork": "S\u00ed\u0165",
+ "HeaderYear": "Rok:",
+ "HeaderYears": "V letech:",
+ "HeaderParentalRating": "Rodi\u010dovsk\u00e9 hodnocen\u00ed",
+ "HeaderCommunityRating": "Hodnocen\u00ed komunity",
+ "HeaderTrailers": "Trailery",
+ "HeaderSpecials": "Speci\u00e1ly",
+ "HeaderGameSystems": "Syst\u00e9m hry",
+ "HeaderPlayers": "Hr\u00e1\u010di:",
+ "HeaderAlbumArtists": "\u00dam\u011blci alba",
+ "HeaderAlbums": "Alba",
+ "HeaderDisc": "Disk",
+ "HeaderTrack": "Stopa",
+ "HeaderAudio": "Zvuk",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Vlo\u017een\u00fd obr\u00e1zek",
+ "HeaderResolution": "Rozli\u0161en\u00ed",
+ "HeaderSubtitles": "Titulky",
+ "HeaderGenres": "\u017d\u00e1nry",
+ "HeaderCountries": "Zem\u011b",
+ "HeaderStatus": "Stav",
+ "HeaderTracks": "Stopy",
+ "HeaderMusicArtist": "Hudebn\u00ed \u00fam\u011blec",
+ "HeaderLocked": "Uzam\u010deno",
+ "HeaderStudios": "Studia",
+ "HeaderActor": "Herci",
+ "HeaderComposer": "Skladatel\u00e9",
+ "HeaderDirector": "Re\u017eis\u00e9\u0159i",
+ "HeaderGuestStar": "Hostuj\u00edc\u00ed hv\u011bzda",
+ "HeaderProducer": "Producenti",
+ "HeaderWriter": "Spisovatel\u00e9",
+ "HeaderParentalRatings": "Rodi\u010dovsk\u00e1 hodnocen\u00ed",
+ "HeaderCommunityRatings": "Hodnocen\u00ed komunity",
+ "StartupEmbyServerIsLoading": "Emby Server je na\u010d\u00edt\u00e1n. Zkuste to pros\u00edm znovu v brzk\u00e9 dob\u011b."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
new file mode 100644
index 000000000..d2a628a80
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Enhed: {1}",
+ "UserDownloadingItemWithValues": "{0} henter {1}",
+ "FolderTypeMixed": "Blandet indhold",
+ "FolderTypeMovies": "FIlm",
+ "FolderTypeMusic": "Musik",
+ "FolderTypeAdultVideos": "Voksenfilm",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "Musikvideoer",
+ "FolderTypeHomeVideos": "Hjemmevideoer",
+ "FolderTypeGames": "Spil",
+ "FolderTypeBooks": "B\u00f8ger",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Nedarv",
+ "HeaderCastCrew": "Medvirkende",
+ "HeaderPeople": "Mennesker",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Kapitel {0}",
+ "NameSeasonNumber": "S\u00e6son {0}",
+ "LabelExit": "Afslut",
+ "LabelVisitCommunity": "Bes\u00f8g F\u00e6lleskab",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api dokumentation",
+ "LabelDeveloperResources": "Udviklerressourcer",
+ "LabelBrowseLibrary": "Gennemse bibliotek",
+ "LabelConfigureServer": "Konfigurer Emby",
+ "LabelRestartServer": "Genstart Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "Bruger",
+ "CategorySystem": "System",
+ "CategoryApplication": "Program",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin fejl",
+ "NotificationOptionApplicationUpdateAvailable": "Programopdatering tilg\u00e6ngelig",
+ "NotificationOptionApplicationUpdateInstalled": "Programopdatering installeret",
+ "NotificationOptionPluginUpdateInstalled": "Opdatering til plugin installeret",
+ "NotificationOptionPluginInstalled": "Plugin installeret",
+ "NotificationOptionPluginUninstalled": "Plugin afinstalleret",
+ "NotificationOptionVideoPlayback": "Videoafspilning startet",
+ "NotificationOptionAudioPlayback": "Lydafspilning startet",
+ "NotificationOptionGamePlayback": "Spilafspilning startet",
+ "NotificationOptionVideoPlaybackStopped": "Videoafspilning stoppet",
+ "NotificationOptionAudioPlaybackStopped": "Lydafspilning stoppet",
+ "NotificationOptionGamePlaybackStopped": "Spilafspilning stoppet",
+ "NotificationOptionTaskFailed": "Fejl i planlagt opgave",
+ "NotificationOptionInstallationFailed": "Fejl ved installation",
+ "NotificationOptionNewLibraryContent": "Nyt indhold tilf\u00f8jet",
+ "NotificationOptionNewLibraryContentMultiple": "Nyt indhold tilf\u00f8jet (flere)",
+ "NotificationOptionCameraImageUploaded": "Kamerabillede tilf\u00f8jet",
+ "NotificationOptionUserLockedOut": "Bruger l\u00e5st",
+ "NotificationOptionServerRestartRequired": "Genstart af serveren p\u00e5kr\u00e6vet",
+ "ViewTypePlaylists": "Afspilningslister",
+ "ViewTypeMovies": "Film",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Spil",
+ "ViewTypeMusic": "Musik",
+ "ViewTypeMusicGenres": "Genrer",
+ "ViewTypeMusicArtists": "Artister",
+ "ViewTypeBoxSets": "Samlinger",
+ "ViewTypeChannels": "Kanaler",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Vises nu",
+ "ViewTypeLatestGames": "Seneste spil",
+ "ViewTypeRecentlyPlayedGames": "Afspillet for nylig",
+ "ViewTypeGameFavorites": "Favoritter",
+ "ViewTypeGameSystems": "Spilsystemer",
+ "ViewTypeGameGenres": "Genrer",
+ "ViewTypeTvResume": "Forts\u00e6t",
+ "ViewTypeTvNextUp": "N\u00e6ste",
+ "ViewTypeTvLatest": "Seneste",
+ "ViewTypeTvShowSeries": "Serier",
+ "ViewTypeTvGenres": "Genrer",
+ "ViewTypeTvFavoriteSeries": "Favoritserier",
+ "ViewTypeTvFavoriteEpisodes": "Favoritepisoder",
+ "ViewTypeMovieResume": "Forts\u00e6t",
+ "ViewTypeMovieLatest": "Seneste",
+ "ViewTypeMovieMovies": "Film",
+ "ViewTypeMovieCollections": "Samlinger",
+ "ViewTypeMovieFavorites": "Favoritter",
+ "ViewTypeMovieGenres": "Genrer",
+ "ViewTypeMusicLatest": "Seneste",
+ "ViewTypeMusicPlaylists": "Afspilningslister",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Albumartister",
+ "HeaderOtherDisplaySettings": "Indstillinger for visning",
+ "ViewTypeMusicSongs": "Sange",
+ "ViewTypeMusicFavorites": "Favoritter",
+ "ViewTypeMusicFavoriteAlbums": "Favoritalbums",
+ "ViewTypeMusicFavoriteArtists": "Favoritartister",
+ "ViewTypeMusicFavoriteSongs": "Favoritsange",
+ "ViewTypeFolders": "Mapper",
+ "ViewTypeLiveTvRecordingGroups": "Optagelser",
+ "ViewTypeLiveTvChannels": "Kanaler",
+ "ScheduledTaskFailedWithName": "{0} fejlede",
+ "LabelRunningTimeValue": "K\u00f8rselstid: {0}",
+ "ScheduledTaskStartedWithName": "{0} startet",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} blev installeret",
+ "PluginUpdatedWithName": "{0} blev opdateret",
+ "PluginUninstalledWithName": "{0} blev afinstalleret",
+ "ItemAddedWithName": "{0} blev tilf\u00f8jet til biblioteket",
+ "ItemRemovedWithName": "{0} blev fjernet fra biblioteket",
+ "LabelIpAddressValue": "IP-adresse: {0}",
+ "DeviceOnlineWithName": "{0} er forbundet",
+ "UserOnlineFromDevice": "{0} er online fra {1}",
+ "ProviderValue": "Udbyder: {0}",
+ "SubtitlesDownloadedForItem": "Undertekster hentet til {0}",
+ "UserConfigurationUpdatedWithName": "Brugerkonfigurationen for {0} er blevet opdateret",
+ "UserCreatedWithName": "Bruger {0} er skabt",
+ "UserPasswordChangedWithName": "Adgangskoden for {0} er blevet \u00e6ndret",
+ "UserDeletedWithName": "Bruger {0} er slettet",
+ "MessageServerConfigurationUpdated": "Serverkonfigurationen er opdateret",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfiguration sektion {0} er opdateret",
+ "MessageApplicationUpdated": "Emby er blevet opdateret",
+ "FailedLoginAttemptWithUserName": "Fejlslagent loginfors\u00f8g fra {0}",
+ "AuthenticationSucceededWithUserName": "{0} autentificeret",
+ "DeviceOfflineWithName": "{0} har afbrudt forbindelsen",
+ "UserLockedOutWithName": "Bruger {0} er blevet l\u00e5st",
+ "UserOfflineFromDevice": "{0} har afbrudt forbindelsen fra {1}",
+ "UserStartedPlayingItemWithValues": "{0} afspiller {1}",
+ "UserStoppedPlayingItemWithValues": "{0} har stoppet afpilningen af {1}",
+ "SubtitleDownloadFailureForItem": "Hentning af undertekster til {0} fejlede",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Bruger",
+ "HeaderName": "Navn",
+ "HeaderDate": "Dato",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Udgivelsesdato",
+ "HeaderRuntime": "Varighed",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "S\u00e6son",
+ "HeaderSeasonNumber": "S\u00e6sonnummer",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Netv\u00e6rk",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "F\u00e6llesskabsvurdering",
+ "HeaderTrailers": "Trailere",
+ "HeaderSpecials": "S\u00e6rudsendelser",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disk",
+ "HeaderTrack": "Spor",
+ "HeaderAudio": "Lyd",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Indlejret billede",
+ "HeaderResolution": "Opl\u00f8sning",
+ "HeaderSubtitles": "Undertekster",
+ "HeaderGenres": "Genrer",
+ "HeaderCountries": "Lande",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Spor",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studier",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Aldersgr\u00e6nser",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
new file mode 100644
index 000000000..30e3d9215
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Bitte warten Sie w\u00e4hrend die Emby Datenbank aktualisiert wird. {0}% verarbeitet.",
+ "AppDeviceValues": "App: {0}, Ger\u00e4t: {1}",
+ "UserDownloadingItemWithValues": "{0} l\u00e4dt {1} herunter",
+ "FolderTypeMixed": "Gemischte Inhalte",
+ "FolderTypeMovies": "Filme",
+ "FolderTypeMusic": "Musik",
+ "FolderTypeAdultVideos": "Videos f\u00fcr Erwachsene",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "Musikvideos",
+ "FolderTypeHomeVideos": "Heimvideos",
+ "FolderTypeGames": "Spiele",
+ "FolderTypeBooks": "B\u00fccher",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "\u00dcbernehmen",
+ "HeaderCastCrew": "Besetzung & Crew",
+ "HeaderPeople": "Personen",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Kapitel {0}",
+ "NameSeasonNumber": "Staffel {0}",
+ "LabelExit": "Beenden",
+ "LabelVisitCommunity": "Besuche die Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Dokumentation",
+ "LabelDeveloperResources": "Entwickler Ressourcen",
+ "LabelBrowseLibrary": "Bibliothek durchsuchen",
+ "LabelConfigureServer": "Konfiguriere Emby",
+ "LabelRestartServer": "Server neustarten",
+ "CategorySync": "Sync",
+ "CategoryUser": "Benutzer",
+ "CategorySystem": "System",
+ "CategoryApplication": "Anwendung",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin Fehler",
+ "NotificationOptionApplicationUpdateAvailable": "Anwendungsaktualisierung verf\u00fcgbar",
+ "NotificationOptionApplicationUpdateInstalled": "Anwendungsaktualisierung installiert",
+ "NotificationOptionPluginUpdateInstalled": "Pluginaktualisierung installiert",
+ "NotificationOptionPluginInstalled": "Plugin installiert",
+ "NotificationOptionPluginUninstalled": "Plugin deinstalliert",
+ "NotificationOptionVideoPlayback": "Videowiedergabe gestartet",
+ "NotificationOptionAudioPlayback": "Audiowiedergabe gestartet",
+ "NotificationOptionGamePlayback": "Spielwiedergabe gestartet",
+ "NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt",
+ "NotificationOptionAudioPlaybackStopped": "Audiowiedergabe gestoppt",
+ "NotificationOptionGamePlaybackStopped": "Spielwiedergabe gestoppt",
+ "NotificationOptionTaskFailed": "Fehler bei geplanter Aufgabe",
+ "NotificationOptionInstallationFailed": "Installationsfehler",
+ "NotificationOptionNewLibraryContent": "Neuer Inhalt hinzugef\u00fcgt",
+ "NotificationOptionNewLibraryContentMultiple": "Neuen Inhalte hinzugef\u00fcgt (mehrere)",
+ "NotificationOptionCameraImageUploaded": "Kamera Bild hochgeladen",
+ "NotificationOptionUserLockedOut": "Benutzer ausgeschlossen",
+ "NotificationOptionServerRestartRequired": "Serverneustart notwendig",
+ "ViewTypePlaylists": "Wiedergabelisten",
+ "ViewTypeMovies": "Filme",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Spiele",
+ "ViewTypeMusic": "Musik",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "K\u00fcnstler",
+ "ViewTypeBoxSets": "Sammlungen",
+ "ViewTypeChannels": "Kan\u00e4le",
+ "ViewTypeLiveTV": "Live-TV",
+ "ViewTypeLiveTvNowPlaying": "Gerade ausgestrahlt",
+ "ViewTypeLatestGames": "Neueste Spiele",
+ "ViewTypeRecentlyPlayedGames": "K\u00fcrzlich abgespielt",
+ "ViewTypeGameFavorites": "Favoriten",
+ "ViewTypeGameSystems": "Spielesysteme",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Fortsetzen",
+ "ViewTypeTvNextUp": "Als n\u00e4chstes",
+ "ViewTypeTvLatest": "Neueste",
+ "ViewTypeTvShowSeries": "Serien",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Serien Favoriten",
+ "ViewTypeTvFavoriteEpisodes": "Episoden Favoriten",
+ "ViewTypeMovieResume": "Fortsetzen",
+ "ViewTypeMovieLatest": "Neueste",
+ "ViewTypeMovieMovies": "Filme",
+ "ViewTypeMovieCollections": "Sammlungen",
+ "ViewTypeMovieFavorites": "Favoriten",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Neueste",
+ "ViewTypeMusicPlaylists": "Wiedergabelisten",
+ "ViewTypeMusicAlbums": "Alben",
+ "ViewTypeMusicAlbumArtists": "Album-K\u00fcnstler",
+ "HeaderOtherDisplaySettings": "Anzeige Einstellungen",
+ "ViewTypeMusicSongs": "Lieder",
+ "ViewTypeMusicFavorites": "Favoriten",
+ "ViewTypeMusicFavoriteAlbums": "Album Favoriten",
+ "ViewTypeMusicFavoriteArtists": "Interpreten Favoriten",
+ "ViewTypeMusicFavoriteSongs": "Lieder Favoriten",
+ "ViewTypeFolders": "Verzeichnisse",
+ "ViewTypeLiveTvRecordingGroups": "Aufnahmen",
+ "ViewTypeLiveTvChannels": "Kan\u00e4le",
+ "ScheduledTaskFailedWithName": "{0} fehlgeschlagen",
+ "LabelRunningTimeValue": "Laufzeit: {0}",
+ "ScheduledTaskStartedWithName": "{0} gestartet",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} wurde installiert",
+ "PluginUpdatedWithName": "{0} wurde aktualisiert",
+ "PluginUninstalledWithName": "{0} wurde deinstalliert",
+ "ItemAddedWithName": "{0} wurde der Bibliothek hinzugef\u00fcgt",
+ "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt",
+ "LabelIpAddressValue": "IP Adresse: {0}",
+ "DeviceOnlineWithName": "{0} ist verbunden",
+ "UserOnlineFromDevice": "{0} ist online von {1}",
+ "ProviderValue": "Anbieter: {0}",
+ "SubtitlesDownloadedForItem": "Untertitel heruntergeladen f\u00fcr {0}",
+ "UserConfigurationUpdatedWithName": "Benutzereinstellungen wurden aktualisiert f\u00fcr {0}",
+ "UserCreatedWithName": "Benutzer {0} wurde erstellt",
+ "UserPasswordChangedWithName": "Das Passwort f\u00fcr Benutzer {0} wurde ge\u00e4ndert",
+ "UserDeletedWithName": "Benutzer {0} wurde gel\u00f6scht",
+ "MessageServerConfigurationUpdated": "Server Einstellungen wurden aktualisiert",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Der Server Einstellungsbereich {0} wurde aktualisiert",
+ "MessageApplicationUpdated": "Emby Server wurde auf den neusten Stand gebracht.",
+ "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
+ "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
+ "DeviceOfflineWithName": "{0} wurde getrennt",
+ "UserLockedOutWithName": "Benutzer {0} wurde ausgeschlossen",
+ "UserOfflineFromDevice": "{0} wurde getrennt von {1}",
+ "UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} gestartet",
+ "UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} beendet",
+ "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen f\u00fcr {0}",
+ "HeaderUnidentified": "Nicht identifiziert",
+ "HeaderImagePrimary": "Bevorzugt",
+ "HeaderImageBackdrop": "Hintergrund",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Benutzerbild",
+ "HeaderOverview": "\u00dcbersicht",
+ "HeaderShortOverview": "Kurz\u00fcbersicht",
+ "HeaderType": "Typ",
+ "HeaderSeverity": "Schwere",
+ "HeaderUser": "Benutzer",
+ "HeaderName": "Name",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premiere Datum",
+ "HeaderDateAdded": "Datum hinzugef\u00fcgt",
+ "HeaderReleaseDate": "Ver\u00f6ffentlichungsdatum",
+ "HeaderRuntime": "Laufzeit",
+ "HeaderPlayCount": "Anzahl Wiedergaben",
+ "HeaderSeason": "Staffel",
+ "HeaderSeasonNumber": "Staffel Nummer",
+ "HeaderSeries": "Serien:",
+ "HeaderNetwork": "Netzwerk",
+ "HeaderYear": "Jahr:",
+ "HeaderYears": "Jahre:",
+ "HeaderParentalRating": "Altersfreigabe",
+ "HeaderCommunityRating": "Community Bewertung",
+ "HeaderTrailers": "Trailer",
+ "HeaderSpecials": "Extras",
+ "HeaderGameSystems": "Spiele Systeme",
+ "HeaderPlayers": "Spieler:",
+ "HeaderAlbumArtists": "Album K\u00fcnstler",
+ "HeaderAlbums": "Alben",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "St\u00fcck",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Integriertes Bild",
+ "HeaderResolution": "Aufl\u00f6sung",
+ "HeaderSubtitles": "Untertitel",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "L\u00e4nder",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Lieder",
+ "HeaderMusicArtist": "Musik K\u00fcnstler",
+ "HeaderLocked": "Blockiert",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Schauspieler",
+ "HeaderComposer": "Komponierer",
+ "HeaderDirector": "Regie",
+ "HeaderGuestStar": "Gaststar",
+ "HeaderProducer": "Produzenten",
+ "HeaderWriter": "Autoren",
+ "HeaderParentalRatings": "Altersbeschr\u00e4nkung",
+ "HeaderCommunityRatings": "Community Bewertungen",
+ "StartupEmbyServerIsLoading": "Emby Server startet, bitte versuchen Sie es gleich noch einmal."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
new file mode 100644
index 000000000..9e2d321cc
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\u0391\u03bd\u03ac\u03bc\u03b5\u03b9\u03ba\u03c4\u03bf \u03a0\u03b5\u03c1\u03b9\u03b5\u03c7\u03cc\u03bc\u03b5\u03bd\u03bf",
+ "FolderTypeMovies": "\u03a4\u03b1\u03b9\u03bd\u03af\u03b5\u03c2",
+ "FolderTypeMusic": "\u039c\u03bf\u03c5\u03c3\u03b9\u03ba\u03ae",
+ "FolderTypeAdultVideos": "\u03a4\u03b1\u03b9\u03bd\u03af\u03b5\u03c2 \u0395\u03bd\u03b7\u03bb\u03af\u03ba\u03c9\u03bd",
+ "FolderTypePhotos": "\u03a6\u03c9\u03c4\u03bf\u03b3\u03c1\u03b1\u03c6\u03af\u03b5\u03c2",
+ "FolderTypeMusicVideos": "\u039c\u03bf\u03c5\u03c3\u03b9\u03ba\u03ac \u0392\u03af\u03bd\u03c4\u03b5\u03bf",
+ "FolderTypeHomeVideos": "\u03a0\u03c1\u03bf\u03c3\u03c9\u03c0\u03b9\u03ba\u03ac \u0392\u03af\u03bd\u03c4\u03b5\u03bf",
+ "FolderTypeGames": "\u03a0\u03b1\u03b9\u03c7\u03bd\u03af\u03b4\u03b9\u03b1",
+ "FolderTypeBooks": "\u0392\u03b9\u03b2\u03bb\u03af\u03b1",
+ "FolderTypeTvShows": "\u03a4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "\u0397\u03b8\u03bf\u03c0\u03bf\u03b9\u03bf\u03af \u03ba\u03b1\u03b9 \u03c3\u03c5\u03bd\u03b5\u03c1\u03b3\u03b5\u03af\u03bf",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u0388\u03be\u03bf\u03b4\u03bf\u03c2",
+ "LabelVisitCommunity": "\u039a\u03bf\u03b9\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "\u03a0\u03b7\u03b3\u03ad\u03c2 \u03a0\u03c1\u03bf\u03b3\u03c1\u03b1\u03bc\u03bc\u03b1\u03c4\u03b9\u03c3\u03c4\u03ae",
+ "LabelBrowseLibrary": "\u03a0\u03b5\u03c1\u03b9\u03b7\u03b3\u03b7\u03b8\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u03b2\u03b9\u03b2\u03bb\u03b9\u03bf\u03b8\u03ae\u03ba\u03b7",
+ "LabelConfigureServer": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 Emby",
+ "LabelRestartServer": "\u0395\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03af\u03bd\u03b7\u03c3\u03b7 \u03b4\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae",
+ "CategorySync": "\u03a3\u03c5\u03c7\u03c1\u03bf\u03bd\u03b9\u03c3\u03bc\u03cc\u03c2",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "\u0389\u03c7\u03bf\u03c2",
+ "HeaderVideo": "\u0392\u03af\u03bd\u03c4\u03b5\u03bf",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
new file mode 100644
index 000000000..493c6c4e9
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Series {0}",
+ "LabelExit": "Exit",
+ "LabelVisitCommunity": "Visit Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Browse Library",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Restart Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New (multiple) content added",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Showing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favourites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favourite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favourite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favourites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favourites",
+ "ViewTypeMusicFavoriteAlbums": "Favourite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favourite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favourite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json
new file mode 100644
index 000000000..bc0dc236d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/en-US.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonUnknown": "Season Unknown",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Exit",
+ "LabelVisitCommunity": "Visit Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Browse Library",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Restart Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json
new file mode 100644
index 000000000..0555aa9d9
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/es-AR.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Salir",
+ "LabelVisitCommunity": "Visit Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentaci\u00f3n API",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Browse Library",
+ "LabelConfigureServer": "Configurar Emby",
+ "LabelRestartServer": "Reiniciar el servidor",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
new file mode 100644
index 000000000..630c7a037
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Por favor espere mientras la base de datos de su Servidor Emby es actualizada. {0}% completo.",
+ "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+ "UserDownloadingItemWithValues": "{0} esta descargando {1}",
+ "FolderTypeMixed": "Contenido mezclado",
+ "FolderTypeMovies": "Pel\u00edculas",
+ "FolderTypeMusic": "M\u00fasica",
+ "FolderTypeAdultVideos": "Videos para adultos",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "Videos musicales",
+ "FolderTypeHomeVideos": "Videos caseros",
+ "FolderTypeGames": "Juegos",
+ "FolderTypeBooks": "Libros",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Heredar",
+ "HeaderCastCrew": "Reparto y Personal",
+ "HeaderPeople": "Personas",
+ "ValueSpecialEpisodeName": "Especial: {0}",
+ "LabelChapterName": "Cap\u00edtulo {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "LabelExit": "Salir",
+ "LabelVisitCommunity": "Visitar la Comunidad",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentaci\u00f3n del API",
+ "LabelDeveloperResources": "Recursos para Desarrolladores",
+ "LabelBrowseLibrary": "Explorar Biblioteca",
+ "LabelConfigureServer": "Configurar Emby",
+ "LabelRestartServer": "Reiniciar el Servidor",
+ "CategorySync": "Sinc.",
+ "CategoryUser": "Usuario",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Aplicaci\u00f3n",
+ "CategoryPlugin": "Complemento",
+ "NotificationOptionPluginError": "Falla de complemento",
+ "NotificationOptionApplicationUpdateAvailable": "Actualizaci\u00f3n de aplicaci\u00f3n disponible",
+ "NotificationOptionApplicationUpdateInstalled": "Actualizaci\u00f3n de aplicaci\u00f3n instalada",
+ "NotificationOptionPluginUpdateInstalled": "Actualizaci\u00f3n de complemento instalada",
+ "NotificationOptionPluginInstalled": "Complemento instalado",
+ "NotificationOptionPluginUninstalled": "Complemento desinstalado",
+ "NotificationOptionVideoPlayback": "Reproducci\u00f3n de video iniciada",
+ "NotificationOptionAudioPlayback": "Reproducci\u00f3n de audio iniciada",
+ "NotificationOptionGamePlayback": "Ejecuci\u00f3n de juego iniciada",
+ "NotificationOptionVideoPlaybackStopped": "Reproducci\u00f3n de video detenida",
+ "NotificationOptionAudioPlaybackStopped": "Reproducci\u00f3n de audio detenida",
+ "NotificationOptionGamePlaybackStopped": "Ejecuci\u00f3n de juego detenida",
+ "NotificationOptionTaskFailed": "Falla de tarea programada",
+ "NotificationOptionInstallationFailed": "Falla de instalaci\u00f3n",
+ "NotificationOptionNewLibraryContent": "Nuevo contenido agregado",
+ "NotificationOptionNewLibraryContentMultiple": "Nuevo contenido agregado (varios)",
+ "NotificationOptionCameraImageUploaded": "Imagen de la c\u00e1mara subida",
+ "NotificationOptionUserLockedOut": "Usuario bloqueado",
+ "NotificationOptionServerRestartRequired": "Reinicio del servidor requerido",
+ "ViewTypePlaylists": "Listas de Reproducci\u00f3n",
+ "ViewTypeMovies": "Pel\u00edculas",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Juegos",
+ "ViewTypeMusic": "M\u00fasica",
+ "ViewTypeMusicGenres": "G\u00e9neros",
+ "ViewTypeMusicArtists": "Artistas",
+ "ViewTypeBoxSets": "Colecciones",
+ "ViewTypeChannels": "Canales",
+ "ViewTypeLiveTV": "TV en Vivo",
+ "ViewTypeLiveTvNowPlaying": "Transmiti\u00e9ndose",
+ "ViewTypeLatestGames": "Juegos Recientes",
+ "ViewTypeRecentlyPlayedGames": "Reproducido Reci\u00e9ntemente",
+ "ViewTypeGameFavorites": "Favoritos",
+ "ViewTypeGameSystems": "Sistemas de Juego",
+ "ViewTypeGameGenres": "G\u00e9neros",
+ "ViewTypeTvResume": "Continuar",
+ "ViewTypeTvNextUp": "A Continuaci\u00f3n",
+ "ViewTypeTvLatest": "Recientes",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "G\u00e9neros",
+ "ViewTypeTvFavoriteSeries": "Series Favoritas",
+ "ViewTypeTvFavoriteEpisodes": "Episodios Favoritos",
+ "ViewTypeMovieResume": "Continuar",
+ "ViewTypeMovieLatest": "Recientes",
+ "ViewTypeMovieMovies": "Pel\u00edculas",
+ "ViewTypeMovieCollections": "Colecciones",
+ "ViewTypeMovieFavorites": "Favoritos",
+ "ViewTypeMovieGenres": "G\u00e9neros",
+ "ViewTypeMusicLatest": "Recientes",
+ "ViewTypeMusicPlaylists": "Listas",
+ "ViewTypeMusicAlbums": "\u00c1lbumes",
+ "ViewTypeMusicAlbumArtists": "Artistas del \u00c1lbum",
+ "HeaderOtherDisplaySettings": "Configuraci\u00f3n de Pantalla",
+ "ViewTypeMusicSongs": "Canciones",
+ "ViewTypeMusicFavorites": "Favoritos",
+ "ViewTypeMusicFavoriteAlbums": "\u00c1lbumes Favoritos",
+ "ViewTypeMusicFavoriteArtists": "Artistas Favoritos",
+ "ViewTypeMusicFavoriteSongs": "Canciones Favoritas",
+ "ViewTypeFolders": "Carpetas",
+ "ViewTypeLiveTvRecordingGroups": "Grabaciones",
+ "ViewTypeLiveTvChannels": "Canales",
+ "ScheduledTaskFailedWithName": "{0} fall\u00f3",
+ "LabelRunningTimeValue": "Duraci\u00f3n: {0}",
+ "ScheduledTaskStartedWithName": "{0} Iniciado",
+ "VersionNumber": "Versi\u00f3n {0}",
+ "PluginInstalledWithName": "{0} fue instalado",
+ "PluginUpdatedWithName": "{0} fue actualizado",
+ "PluginUninstalledWithName": "{0} fue desinstalado",
+ "ItemAddedWithName": "{0} fue agregado a la biblioteca",
+ "ItemRemovedWithName": "{0} fue removido de la biblioteca",
+ "LabelIpAddressValue": "Direcci\u00f3n IP: {0}",
+ "DeviceOnlineWithName": "{0} est\u00e1 conectado",
+ "UserOnlineFromDevice": "{0} est\u00e1 en l\u00ednea desde {1}",
+ "ProviderValue": "Proveedor: {0}",
+ "SubtitlesDownloadedForItem": "Subt\u00edtulos descargados para {0}",
+ "UserConfigurationUpdatedWithName": "Se ha actualizado la configuraci\u00f3n del usuario {0}",
+ "UserCreatedWithName": "Se ha creado el usuario {0}",
+ "UserPasswordChangedWithName": "Se ha cambiado la contrase\u00f1a para el usuario {0}",
+ "UserDeletedWithName": "Se ha eliminado al usuario {0}",
+ "MessageServerConfigurationUpdated": "Se ha actualizado la configuraci\u00f3n del servidor",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la secci\u00f3n {0} de la configuraci\u00f3n del servidor",
+ "MessageApplicationUpdated": "El servidor Emby ha sido actualizado",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesi\u00f3n de {0}",
+ "AuthenticationSucceededWithUserName": "{0} autenticado con \u00e9xito",
+ "DeviceOfflineWithName": "{0} se ha desconectado",
+ "UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
+ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}",
+ "UserStartedPlayingItemWithValues": "{0} ha iniciado la reproducci\u00f3n de {1}",
+ "UserStoppedPlayingItemWithValues": "{0} ha detenido la reproducci\u00f3n de {1}",
+ "SubtitleDownloadFailureForItem": "Fall\u00f3 la descarga de subt\u00edtulos para {0}",
+ "HeaderUnidentified": "No Identificado",
+ "HeaderImagePrimary": "Principal",
+ "HeaderImageBackdrop": "Imagen de Fondo",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Imagen de Usuario",
+ "HeaderOverview": "Resumen",
+ "HeaderShortOverview": "Sinopsis corta:",
+ "HeaderType": "Tipo",
+ "HeaderSeverity": "Severidad",
+ "HeaderUser": "Usuario",
+ "HeaderName": "Nombre",
+ "HeaderDate": "Fecha",
+ "HeaderPremiereDate": "Fecha de Estreno",
+ "HeaderDateAdded": "Fecha de Adici\u00f3n",
+ "HeaderReleaseDate": "Fecha de estreno",
+ "HeaderRuntime": "Duraci\u00f3n",
+ "HeaderPlayCount": "Contador",
+ "HeaderSeason": "Temporada",
+ "HeaderSeasonNumber": "N\u00famero de temporada",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Cadena",
+ "HeaderYear": "A\u00f1o:",
+ "HeaderYears": "A\u00f1os:",
+ "HeaderParentalRating": "Clasificaci\u00f3n Parental",
+ "HeaderCommunityRating": "Calificaci\u00f3n de la comunidad",
+ "HeaderTrailers": "Tr\u00e1ilers",
+ "HeaderSpecials": "Especiales",
+ "HeaderGameSystems": "Sistemas de Juego",
+ "HeaderPlayers": "Reproductores:",
+ "HeaderAlbumArtists": "Artistas del \u00c1lbum",
+ "HeaderAlbums": "\u00c1lbumes",
+ "HeaderDisc": "Disco",
+ "HeaderTrack": "Pista",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Im\u00e1gen embebida",
+ "HeaderResolution": "Resoluci\u00f3n",
+ "HeaderSubtitles": "Subt\u00edtulos",
+ "HeaderGenres": "G\u00e9neros",
+ "HeaderCountries": "Pa\u00edses",
+ "HeaderStatus": "Estado",
+ "HeaderTracks": "Pistas",
+ "HeaderMusicArtist": "Int\u00e9rprete",
+ "HeaderLocked": "Bloqueado",
+ "HeaderStudios": "Estudios",
+ "HeaderActor": "Actores",
+ "HeaderComposer": "Compositores",
+ "HeaderDirector": "Directores",
+ "HeaderGuestStar": "Estrella invitada",
+ "HeaderProducer": "Productores",
+ "HeaderWriter": "Guionistas",
+ "HeaderParentalRatings": "Clasificaci\u00f3n Parental",
+ "HeaderCommunityRatings": "Clasificaciones de la comunidad",
+ "StartupEmbyServerIsLoading": "El servidor Emby esta cargando. Por favor intente de nuevo dentro de poco."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
new file mode 100644
index 000000000..d1a56240d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Por favor espere mientras la base de datos de su servidor Emby se actualiza. {0}% completado.",
+ "AppDeviceValues": "Aplicaci\u00f3n: {0}, Dispositivo: {1}",
+ "UserDownloadingItemWithValues": "{0} est\u00e1 descargando {1}",
+ "FolderTypeMixed": "Contenido mezclado",
+ "FolderTypeMovies": "Peliculas",
+ "FolderTypeMusic": "Musica",
+ "FolderTypeAdultVideos": "Videos para adultos",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "Videos Musicales",
+ "FolderTypeHomeVideos": "Videos caseros",
+ "FolderTypeGames": "Juegos",
+ "FolderTypeBooks": "Libros",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Heredado",
+ "HeaderCastCrew": "Reparto y equipo t\u00e9cnico",
+ "HeaderPeople": "Gente",
+ "ValueSpecialEpisodeName": "Especial - {0}",
+ "LabelChapterName": "Cap\u00edtulo {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "LabelExit": "Salir",
+ "LabelVisitCommunity": "Visitar la comunidad",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentaci\u00f3n API",
+ "LabelDeveloperResources": "Recursos del Desarrollador",
+ "LabelBrowseLibrary": "Navegar biblioteca",
+ "LabelConfigureServer": "Configurar Emby",
+ "LabelRestartServer": "Reiniciar el servidor",
+ "CategorySync": "Sincronizar",
+ "CategoryUser": "Usuario",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Aplicaci\u00f3n",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Error en plugin",
+ "NotificationOptionApplicationUpdateAvailable": "Disponible actualizaci\u00f3n de la aplicaci\u00f3n",
+ "NotificationOptionApplicationUpdateInstalled": "Se ha instalado la actualizaci\u00f3n de la aplicaci\u00f3n",
+ "NotificationOptionPluginUpdateInstalled": "Se ha instalado la actualizaci\u00f3n del plugin",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "NotificationOptionVideoPlayback": "Reproduccion de video a iniciado",
+ "NotificationOptionAudioPlayback": "Reproduccion de audio a iniciado",
+ "NotificationOptionGamePlayback": "Reproduccion de video juego a iniciado",
+ "NotificationOptionVideoPlaybackStopped": "Reproducci\u00f3n de video detenida",
+ "NotificationOptionAudioPlaybackStopped": "Reproducci\u00f3n de audio detenida",
+ "NotificationOptionGamePlaybackStopped": "Reproducci\u00f3n de juego detenida",
+ "NotificationOptionTaskFailed": "La tarea programada ha fallado",
+ "NotificationOptionInstallationFailed": "Fallo en la instalaci\u00f3n",
+ "NotificationOptionNewLibraryContent": "Nuevo contenido a\u00f1adido",
+ "NotificationOptionNewLibraryContentMultiple": "Nuevo contenido a\u00f1adido (multiple)",
+ "NotificationOptionCameraImageUploaded": "Imagen de camara se a carcado",
+ "NotificationOptionUserLockedOut": "Usuario bloqueado",
+ "NotificationOptionServerRestartRequired": "Se requiere el reinicio del servidor",
+ "ViewTypePlaylists": "Listas de reproducci\u00f3n",
+ "ViewTypeMovies": "Pel\u00edculas",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Juegos",
+ "ViewTypeMusic": "M\u00fasica",
+ "ViewTypeMusicGenres": "G\u00e9neros",
+ "ViewTypeMusicArtists": "Artistas",
+ "ViewTypeBoxSets": "Colecciones",
+ "ViewTypeChannels": "Canales",
+ "ViewTypeLiveTV": "Tv en vivo",
+ "ViewTypeLiveTvNowPlaying": "Transmiti\u00e9ndose ahora",
+ "ViewTypeLatestGames": "\u00daltimos juegos",
+ "ViewTypeRecentlyPlayedGames": "Reproducido recientemente",
+ "ViewTypeGameFavorites": "Favoritos",
+ "ViewTypeGameSystems": "Sistemas de juego",
+ "ViewTypeGameGenres": "G\u00e9neros",
+ "ViewTypeTvResume": "Reanudar",
+ "ViewTypeTvNextUp": "Pr\u00f3ximamente",
+ "ViewTypeTvLatest": "\u00daltimas",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "G\u00e9neros",
+ "ViewTypeTvFavoriteSeries": "Series favoritas",
+ "ViewTypeTvFavoriteEpisodes": "Episodios favoritos",
+ "ViewTypeMovieResume": "Reanudar",
+ "ViewTypeMovieLatest": "\u00daltimas",
+ "ViewTypeMovieMovies": "Pel\u00edculas",
+ "ViewTypeMovieCollections": "Colecciones",
+ "ViewTypeMovieFavorites": "Favoritos",
+ "ViewTypeMovieGenres": "G\u00e9neros",
+ "ViewTypeMusicLatest": "\u00daltimas",
+ "ViewTypeMusicPlaylists": "Lista",
+ "ViewTypeMusicAlbums": "\u00c1lbumes",
+ "ViewTypeMusicAlbumArtists": "\u00c1lbumes de artistas",
+ "HeaderOtherDisplaySettings": "Configuraci\u00f3n de pantalla",
+ "ViewTypeMusicSongs": "Canciones",
+ "ViewTypeMusicFavorites": "Favoritos",
+ "ViewTypeMusicFavoriteAlbums": "\u00c1lbumes favoritos",
+ "ViewTypeMusicFavoriteArtists": "Artistas favoritos",
+ "ViewTypeMusicFavoriteSongs": "Canciones favoritas",
+ "ViewTypeFolders": "Carpetas",
+ "ViewTypeLiveTvRecordingGroups": "Grabaciones",
+ "ViewTypeLiveTvChannels": "Canales",
+ "ScheduledTaskFailedWithName": "{0} fall\u00f3",
+ "LabelRunningTimeValue": "Tiempo de ejecuci\u00f3n: {0}",
+ "ScheduledTaskStartedWithName": "{0} iniciado",
+ "VersionNumber": "Versi\u00f3n {0}",
+ "PluginInstalledWithName": "{0} ha sido instalado",
+ "PluginUpdatedWithName": "{0} ha sido actualizado",
+ "PluginUninstalledWithName": "{0} ha sido desinstalado",
+ "ItemAddedWithName": "{0} ha sido a\u00f1adido a la biblioteca",
+ "ItemRemovedWithName": "{0} se ha eliminado de la biblioteca",
+ "LabelIpAddressValue": "Direcci\u00f3n IP: {0}",
+ "DeviceOnlineWithName": "{0} est\u00e1 conectado",
+ "UserOnlineFromDevice": "{0} est\u00e1 conectado desde {1}",
+ "ProviderValue": "Proveedor: {0}",
+ "SubtitlesDownloadedForItem": "Subt\u00edtulos descargados para {0}",
+ "UserConfigurationUpdatedWithName": "Se ha actualizado la configuraci\u00f3n de usuario para {0}",
+ "UserCreatedWithName": "Se ha creado el usuario {0}",
+ "UserPasswordChangedWithName": "Contrase\u00f1a cambiada al usuario {0}",
+ "UserDeletedWithName": "El usuario {0} ha sido eliminado",
+ "MessageServerConfigurationUpdated": "Se ha actualizado la configuraci\u00f3n del servidor",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la secci\u00f3n {0} de la configuraci\u00f3n del servidor",
+ "MessageApplicationUpdated": "Se ha actualizado el servidor Emby",
+ "FailedLoginAttemptWithUserName": "Intento de inicio de sesi\u00f3n fallido desde {0}",
+ "AuthenticationSucceededWithUserName": "{0} se ha autenticado satisfactoriamente",
+ "DeviceOfflineWithName": "{0} se ha desconectado",
+ "UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
+ "UserOfflineFromDevice": "{0} se ha desconectado de {1}",
+ "UserStartedPlayingItemWithValues": "{0} ha empezado a reproducir {1}",
+ "UserStoppedPlayingItemWithValues": "{0} ha parado de reproducir {1}",
+ "SubtitleDownloadFailureForItem": "Fallo en la descarga de subt\u00edtulos para {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Usuario",
+ "HeaderName": "Nombre",
+ "HeaderDate": "Fecha",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subt\u00edtulos",
+ "HeaderGenres": "G\u00e9neros",
+ "HeaderCountries": "Paises",
+ "HeaderStatus": "Estado",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Estudios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Clasificaci\u00f3n parental",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
new file mode 100644
index 000000000..20efa1406
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Poistu",
+ "LabelVisitCommunity": "K\u00e4y Yhteis\u00f6ss\u00e4",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Selaa Kirjastoa",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "K\u00e4ynnist\u00e4 Palvelin uudelleen",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json
new file mode 100644
index 000000000..789817c84
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Veuillez patienter pendant que la base de donn\u00e9e de votre Serveur Emby se met \u00e0 jour. Termin\u00e9e \u00e0 {0}%.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Quitter",
+ "LabelVisitCommunity": "Visiter la Communaut\u00e9",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentation de l'API",
+ "LabelDeveloperResources": "Ressources pour d\u00e9veloppeurs",
+ "LabelBrowseLibrary": "Parcourir la biblioth\u00e8que",
+ "LabelConfigureServer": "Configurer Emby",
+ "LabelRestartServer": "Red\u00e9marrer le Serveur",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
new file mode 100644
index 000000000..25c722989
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Veuillez patienter pendant que la base de donn\u00e9e de votre Emby Serveur se met \u00e0 jour. Termin\u00e9e \u00e0 {0}%.",
+ "AppDeviceValues": "Application : {0}, Appareil: {1}",
+ "UserDownloadingItemWithValues": "{0} est en train de t\u00e9l\u00e9charger {1}",
+ "FolderTypeMixed": "Contenus m\u00e9lang\u00e9s",
+ "FolderTypeMovies": "Films",
+ "FolderTypeMusic": "Musique",
+ "FolderTypeAdultVideos": "Vid\u00e9os Adultes",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Vid\u00e9os Musical",
+ "FolderTypeHomeVideos": "Vid\u00e9os personnelles",
+ "FolderTypeGames": "Jeux",
+ "FolderTypeBooks": "Livres",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "H\u00e9rite",
+ "HeaderCastCrew": "\u00c9quipe de tournage",
+ "HeaderPeople": "Personnes",
+ "ValueSpecialEpisodeName": "Sp\u00e9cial - {0}",
+ "LabelChapterName": "Chapitre {0}",
+ "NameSeasonNumber": "Saison {0}",
+ "LabelExit": "Quitter",
+ "LabelVisitCommunity": "Visiter la Communaut\u00e9",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentation de l'API",
+ "LabelDeveloperResources": "Ressources pour d\u00e9veloppeurs",
+ "LabelBrowseLibrary": "Parcourir la biblioth\u00e8que",
+ "LabelConfigureServer": "Configurer Emby",
+ "LabelRestartServer": "Red\u00e9marrer le Serveur",
+ "CategorySync": "Sync",
+ "CategoryUser": "Utilisateur",
+ "CategorySystem": "Syst\u00e8me",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Erreur de plugin",
+ "NotificationOptionApplicationUpdateAvailable": "Mise \u00e0 jour d'application disponible",
+ "NotificationOptionApplicationUpdateInstalled": "Mise \u00e0 jour d'application install\u00e9e",
+ "NotificationOptionPluginUpdateInstalled": "Mise \u00e0 jour de plugin install\u00e9e",
+ "NotificationOptionPluginInstalled": "Plugin install\u00e9",
+ "NotificationOptionPluginUninstalled": "Plugin d\u00e9sinstall\u00e9",
+ "NotificationOptionVideoPlayback": "Lecture vid\u00e9o d\u00e9marr\u00e9e",
+ "NotificationOptionAudioPlayback": "Lecture audio d\u00e9marr\u00e9e",
+ "NotificationOptionGamePlayback": "Lecture de jeu d\u00e9marr\u00e9e",
+ "NotificationOptionVideoPlaybackStopped": "Lecture vid\u00e9o arr\u00eat\u00e9e",
+ "NotificationOptionAudioPlaybackStopped": "Lecture audio arr\u00eat\u00e9e",
+ "NotificationOptionGamePlaybackStopped": "Lecture de jeu arr\u00eat\u00e9e",
+ "NotificationOptionTaskFailed": "\u00c9chec de t\u00e2che planifi\u00e9e",
+ "NotificationOptionInstallationFailed": "\u00c9chec d'installation",
+ "NotificationOptionNewLibraryContent": "Nouveau contenu ajout\u00e9",
+ "NotificationOptionNewLibraryContentMultiple": "Nouveau contenu ajout\u00e9 (multiple)",
+ "NotificationOptionCameraImageUploaded": "L'image de l'appareil photo a \u00e9t\u00e9 upload\u00e9e",
+ "NotificationOptionUserLockedOut": "Utilisateur verrouill\u00e9",
+ "NotificationOptionServerRestartRequired": "Un red\u00e9marrage du serveur est requis",
+ "ViewTypePlaylists": "Listes de lecture",
+ "ViewTypeMovies": "Films",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Jeux",
+ "ViewTypeMusic": "Musique",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artistes",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Cha\u00eenes",
+ "ViewTypeLiveTV": "TV en direct",
+ "ViewTypeLiveTvNowPlaying": "En cours de diffusion",
+ "ViewTypeLatestGames": "Derniers jeux",
+ "ViewTypeRecentlyPlayedGames": "R\u00e9cemment jou\u00e9",
+ "ViewTypeGameFavorites": "Favoris",
+ "ViewTypeGameSystems": "Syst\u00e8me de jeu",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Reprise",
+ "ViewTypeTvNextUp": "A venir",
+ "ViewTypeTvLatest": "Derniers",
+ "ViewTypeTvShowSeries": "S\u00e9ries",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "S\u00e9ries favorites",
+ "ViewTypeTvFavoriteEpisodes": "Episodes favoris",
+ "ViewTypeMovieResume": "Reprise",
+ "ViewTypeMovieLatest": "Dernier",
+ "ViewTypeMovieMovies": "Films",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favoris",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Dernier",
+ "ViewTypeMusicPlaylists": "Listes de lectures",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Artiste de l'album",
+ "HeaderOtherDisplaySettings": "Param\u00e8tres d'affichage",
+ "ViewTypeMusicSongs": "Chansons",
+ "ViewTypeMusicFavorites": "Favoris",
+ "ViewTypeMusicFavoriteAlbums": "Albums favoris",
+ "ViewTypeMusicFavoriteArtists": "Artistes favoris",
+ "ViewTypeMusicFavoriteSongs": "Chansons favorites",
+ "ViewTypeFolders": "R\u00e9pertoires",
+ "ViewTypeLiveTvRecordingGroups": "Enregistrements",
+ "ViewTypeLiveTvChannels": "Cha\u00eenes",
+ "ScheduledTaskFailedWithName": "{0} a \u00e9chou\u00e9",
+ "LabelRunningTimeValue": "Dur\u00e9e: {0}",
+ "ScheduledTaskStartedWithName": "{0} a commenc\u00e9",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} a \u00e9t\u00e9 install\u00e9",
+ "PluginUpdatedWithName": "{0} a \u00e9t\u00e9 mis \u00e0 jour",
+ "PluginUninstalledWithName": "{0} a \u00e9t\u00e9 d\u00e9sinstall\u00e9",
+ "ItemAddedWithName": "{0} a \u00e9t\u00e9 ajout\u00e9 \u00e0 la biblioth\u00e8que",
+ "ItemRemovedWithName": "{0} a \u00e9t\u00e9 supprim\u00e9 de la biblioth\u00e8que",
+ "LabelIpAddressValue": "Adresse IP: {0}",
+ "DeviceOnlineWithName": "{0} est connect\u00e9",
+ "UserOnlineFromDevice": "{0} s'est connect\u00e9 depuis {1}",
+ "ProviderValue": "Fournisseur : {0}",
+ "SubtitlesDownloadedForItem": "Les sous-titres de {0} ont \u00e9t\u00e9 t\u00e9l\u00e9charg\u00e9s",
+ "UserConfigurationUpdatedWithName": "La configuration utilisateur de {0} a \u00e9t\u00e9 mise \u00e0 jour",
+ "UserCreatedWithName": "L'utilisateur {0} a \u00e9t\u00e9 cr\u00e9\u00e9.",
+ "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a \u00e9t\u00e9 modifi\u00e9.",
+ "UserDeletedWithName": "L'utilisateur {0} a \u00e9t\u00e9 supprim\u00e9.",
+ "MessageServerConfigurationUpdated": "La configuration du serveur a \u00e9t\u00e9 mise \u00e0 jour.",
+ "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a \u00e9t\u00e9 mise \u00e0 jour.",
+ "MessageApplicationUpdated": "Le serveur Emby a \u00e9t\u00e9 mis \u00e0 jour",
+ "FailedLoginAttemptWithUserName": "Echec d'une tentative de connexion de {0}",
+ "AuthenticationSucceededWithUserName": "{0} s'est authentifi\u00e9 avec succ\u00e8s",
+ "DeviceOfflineWithName": "{0} s'est d\u00e9connect\u00e9",
+ "UserLockedOutWithName": "L'utilisateur {0} a \u00e9t\u00e9 verrouill\u00e9",
+ "UserOfflineFromDevice": "{0} s'est d\u00e9connect\u00e9 depuis {1}",
+ "UserStartedPlayingItemWithValues": "{0} vient de commencer la lecture de {1}",
+ "UserStoppedPlayingItemWithValues": "{0} vient d'arr\u00eater la lecture de {1}",
+ "SubtitleDownloadFailureForItem": "Le t\u00e9l\u00e9chargement des sous-titres pour {0} a \u00e9chou\u00e9.",
+ "HeaderUnidentified": "Non identifi\u00e9",
+ "HeaderImagePrimary": "Primaire",
+ "HeaderImageBackdrop": "Contexte",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Avatar de l'utilisateur",
+ "HeaderOverview": "Aper\u00e7u",
+ "HeaderShortOverview": "Synopsys",
+ "HeaderType": "Type",
+ "HeaderSeverity": "S\u00e9v\u00e9rit\u00e9",
+ "HeaderUser": "Utilisateur",
+ "HeaderName": "Nom",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Date de la Premi\u00e8re",
+ "HeaderDateAdded": "Date d'ajout",
+ "HeaderReleaseDate": "Date de sortie ",
+ "HeaderRuntime": "Dur\u00e9e",
+ "HeaderPlayCount": "Nombre de lectures",
+ "HeaderSeason": "Saison",
+ "HeaderSeasonNumber": "Num\u00e9ro de saison",
+ "HeaderSeries": "S\u00e9ries :",
+ "HeaderNetwork": "R\u00e9seau",
+ "HeaderYear": "Ann\u00e9e :",
+ "HeaderYears": "Ann\u00e9es :",
+ "HeaderParentalRating": "Classification parentale",
+ "HeaderCommunityRating": "Note de la communaut\u00e9",
+ "HeaderTrailers": "Bandes-annonces",
+ "HeaderSpecials": "Episodes sp\u00e9ciaux",
+ "HeaderGameSystems": "Plateformes de jeu",
+ "HeaderPlayers": "Lecteurs :",
+ "HeaderAlbumArtists": "Artistes sur l'album",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disque",
+ "HeaderTrack": "Piste",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Vid\u00e9o",
+ "HeaderEmbeddedImage": "Image int\u00e9gr\u00e9e",
+ "HeaderResolution": "R\u00e9solution",
+ "HeaderSubtitles": "Sous-titres",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Pays",
+ "HeaderStatus": "\u00c9tat",
+ "HeaderTracks": "Pistes",
+ "HeaderMusicArtist": "Artiste de l'album",
+ "HeaderLocked": "Verrouill\u00e9",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Acteurs",
+ "HeaderComposer": "Compositeurs",
+ "HeaderDirector": "R\u00e9alisateurs",
+ "HeaderGuestStar": "R\u00f4le principal",
+ "HeaderProducer": "Producteurs",
+ "HeaderWriter": "Auteur(e)s",
+ "HeaderParentalRatings": "Note parentale",
+ "HeaderCommunityRatings": "Classification de la communaut\u00e9",
+ "StartupEmbyServerIsLoading": "Le serveur Emby est en cours de chargement. Veuillez r\u00e9essayer dans quelques instant."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json
new file mode 100644
index 000000000..88af82b7e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/gsw.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Verschiedeni Sache",
+ "FolderTypeMovies": "Film",
+ "FolderTypeMusic": "Musig",
+ "FolderTypeAdultVideos": "Erwachseni Film",
+ "FolderTypePhotos": "F\u00f6teli",
+ "FolderTypeMusicVideos": "Musigvideos",
+ "FolderTypeHomeVideos": "Heimvideos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "B\u00fcecher",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "erbf\u00e4hig",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Verlasse",
+ "LabelVisitCommunity": "Bsuech d'Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "API Dokumentatione",
+ "LabelDeveloperResources": "Entwickler Ressurce",
+ "LabelBrowseLibrary": "Dursuech d'Bibliothek",
+ "LabelConfigureServer": "Konfigurier Emby",
+ "LabelRestartServer": "Server neustarte",
+ "CategorySync": "Synchronisierig",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
new file mode 100644
index 000000000..137b45544
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\u05ea\u05d5\u05db\u05df \u05de\u05e2\u05d5\u05e8\u05d1",
+ "FolderTypeMovies": "\u05e1\u05e8\u05d8\u05d9\u05dd",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "\u05d8\u05dc\u05d5\u05d9\u05d6\u05d9\u05d4",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "\u05e9\u05d7\u05e7\u05e0\u05d9\u05dd \u05d5\u05e6\u05d5\u05d5\u05ea",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u05d9\u05e6\u05d9\u05d0\u05d4",
+ "LabelVisitCommunity": "\u05d1\u05e7\u05e8 \u05d1\u05e7\u05d4\u05d9\u05dc\u05d4",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "\u05ea\u05d9\u05e2\u05d5\u05d3 API",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "\u05d3\u05e4\u05d3\u05e3 \u05d1\u05e1\u05e4\u05e8\u05d9\u05d4",
+ "LabelConfigureServer": "\u05e7\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05ea Emby",
+ "LabelRestartServer": "\u05d0\u05ea\u05d7\u05dc \u05d0\u05ea \u05d4\u05e9\u05e8\u05ea",
+ "CategorySync": "\u05e1\u05e0\u05db\u05e8\u05df",
+ "CategoryUser": "\u05de\u05e9\u05ea\u05de\u05e9",
+ "CategorySystem": "\u05de\u05e2\u05e8\u05db\u05ea",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "\u05ea\u05e7\u05dc\u05d4 \u05d1\u05ea\u05d5\u05e1\u05e3",
+ "NotificationOptionApplicationUpdateAvailable": "\u05e2\u05d3\u05db\u05d5\u05df \u05ea\u05d5\u05db\u05de\u05d4 \u05e7\u05d9\u05d9\u05dd",
+ "NotificationOptionApplicationUpdateInstalled": "\u05e2\u05d3\u05db\u05d5\u05df \u05ea\u05d5\u05db\u05e0\u05d4 \u05d4\u05d5\u05ea\u05e7\u05df",
+ "NotificationOptionPluginUpdateInstalled": "\u05e2\u05d3\u05db\u05d5\u05df \u05ea\u05d5\u05e1\u05e3 \u05d4\u05d5\u05ea\u05e7\u05df",
+ "NotificationOptionPluginInstalled": "\u05ea\u05d5\u05e1\u05e3 \u05d4\u05d5\u05ea\u05e7\u05df",
+ "NotificationOptionPluginUninstalled": "\u05ea\u05d5\u05e1\u05e3 \u05d4\u05d5\u05e1\u05e8",
+ "NotificationOptionVideoPlayback": "\u05e0\u05d2\u05d9\u05e0\u05ea \u05d5\u05d9\u05d3\u05d0\u05d5 \u05d4\u05d7\u05dc\u05d4",
+ "NotificationOptionAudioPlayback": "\u05e0\u05d2\u05d9\u05e0\u05ea \u05e6\u05dc\u05d9\u05dc \u05d4\u05d7\u05dc\u05d4",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "\u05e0\u05d2\u05d9\u05e0\u05ea \u05d5\u05d9\u05d3\u05d0\u05d5 \u05d4\u05d5\u05e4\u05e1\u05e7\u05d4",
+ "NotificationOptionAudioPlaybackStopped": "\u05e0\u05d2\u05d9\u05e0\u05ea \u05e6\u05dc\u05d9\u05dc \u05d4\u05d5\u05e4\u05e1\u05e7\u05d4",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "\u05de\u05e9\u05d9\u05de\u05d4 \u05de\u05ea\u05d5\u05d6\u05de\u05e0\u05ea \u05e0\u05db\u05e9\u05dc\u05d4",
+ "NotificationOptionInstallationFailed": "\u05d4\u05ea\u05e7\u05e0\u05d4 \u05e0\u05db\u05e9\u05dc\u05d4",
+ "NotificationOptionNewLibraryContent": "\u05ea\u05d5\u05db\u05df \u05d7\u05d3\u05e9 \u05e0\u05d5\u05e1\u05e3",
+ "NotificationOptionNewLibraryContentMultiple": "\u05d4\u05ea\u05d5\u05d5\u05e1\u05e4\u05d5 \u05ea\u05db\u05e0\u05d9\u05dd \u05d7\u05d3\u05e9\u05d9\u05dd",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "\u05e0\u05d3\u05e8\u05e9\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4 \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05d4\u05e9\u05e8\u05ea",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "\u05e1\u05e8\u05d8\u05d9\u05dd",
+ "ViewTypeTvShows": "\u05d8\u05dc\u05d5\u05d9\u05d6\u05d9\u05d4",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "\u05e1\u05e8\u05d8\u05d9\u05dd",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "\u05d2\u05d9\u05e8\u05e1\u05d0 {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "\u05e9\u05dd",
+ "HeaderDate": "\u05ea\u05d0\u05e8\u05d9\u05da",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "\u05ea\u05d0\u05e8\u05d9\u05da \u05d4\u05d5\u05e1\u05e4\u05d4",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "\u05e1\u05d3\u05e8\u05d4",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "\u05e9\u05e0\u05d4",
+ "HeaderYears": "\u05e9\u05e0\u05d9\u05dd",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "\u05de\u05e6\u05d1",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "\u05e9\u05d7\u05e7\u05e0\u05d9\u05dd",
+ "HeaderComposer": "\u05de\u05dc\u05d7\u05d9\u05e0\u05d9\u05dd",
+ "HeaderDirector": "\u05d1\u05de\u05d0\u05d9\u05dd",
+ "HeaderGuestStar": "\u05d0\u05de\u05df \u05d0\u05d5\u05e8\u05d7",
+ "HeaderProducer": "\u05de\u05e4\u05d9\u05e7\u05d9\u05dd",
+ "HeaderWriter": "\u05db\u05d5\u05ea\u05d1\u05d9\u05dd",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
new file mode 100644
index 000000000..7a94dc32b
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Glumci i ekipa",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Izlaz",
+ "LabelVisitCommunity": "Posjeti zajednicu",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Pregledaj biblioteku",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Restartiraj Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "Korisnik",
+ "CategorySystem": "Sistem",
+ "CategoryApplication": "Aplikacija",
+ "CategoryPlugin": "Dodatak",
+ "NotificationOptionPluginError": "Dodatak otkazao",
+ "NotificationOptionApplicationUpdateAvailable": "Dostupno a\u017euriranje aplikacije",
+ "NotificationOptionApplicationUpdateInstalled": "Instalirano a\u017euriranje aplikacije",
+ "NotificationOptionPluginUpdateInstalled": "Instalirano a\u017euriranje za dodatak",
+ "NotificationOptionPluginInstalled": "Dodatak instaliran",
+ "NotificationOptionPluginUninstalled": "Dodatak uklonjen",
+ "NotificationOptionVideoPlayback": "Reprodukcija videa zapo\u010deta",
+ "NotificationOptionAudioPlayback": "Reprodukcija glazbe zapo\u010deta",
+ "NotificationOptionGamePlayback": "Igrica pokrenuta",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Zakazan zadatak nije izvr\u0161en",
+ "NotificationOptionInstallationFailed": "Instalacija nije izvr\u0161ena",
+ "NotificationOptionNewLibraryContent": "Novi sadr\u017eaj dodan",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Verzija {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Ime",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
new file mode 100644
index 000000000..2b9d28d8c
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "K\u00e9rlek v\u00e1rj, m\u00edg az Emby Szerver adatb\u00e1zis friss\u00fcl. {0}% k\u00e9sz.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Vegyes tartalom",
+ "FolderTypeMovies": "Filmek",
+ "FolderTypeMusic": "Zen\u00e9k",
+ "FolderTypeAdultVideos": "Feln\u0151tt vide\u00f3k",
+ "FolderTypePhotos": "F\u00e9nyk\u00e9pek",
+ "FolderTypeMusicVideos": "Zenei vide\u00f3k",
+ "FolderTypeHomeVideos": "H\u00e1zi vide\u00f3k",
+ "FolderTypeGames": "J\u00e1t\u00e9kok",
+ "FolderTypeBooks": "K\u00f6nyvek",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Szerepl\u0151k & R\u00e9sztvev\u0151k",
+ "HeaderPeople": "Emberek",
+ "ValueSpecialEpisodeName": "K\u00fcl\u00f6nleges - {0}",
+ "LabelChapterName": "Fejezet {0}",
+ "NameSeasonNumber": "\u00c9vad {0}",
+ "LabelExit": "Kil\u00e9p\u00e9s",
+ "LabelVisitCommunity": "K\u00f6z\u00f6ss\u00e9g",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api dokument\u00e1ci\u00f3",
+ "LabelDeveloperResources": "Fejleszt\u0151i eszk\u00f6z\u00f6k",
+ "LabelBrowseLibrary": "M\u00e9diat\u00e1r tall\u00f3z\u00e1sa",
+ "LabelConfigureServer": "Emby konfigur\u00e1l\u00e1sa",
+ "LabelRestartServer": "Szerver \u00fajraindit\u00e1sa",
+ "CategorySync": "Sync",
+ "CategoryUser": "Felhaszn\u00e1l\u00f3",
+ "CategorySystem": "Rendszer",
+ "CategoryApplication": "Alkalmaz\u00e1s",
+ "CategoryPlugin": "B\u0151v\u00edtm\u00e9ny",
+ "NotificationOptionPluginError": "B\u0151v\u00edtm\u00e9ny hiba",
+ "NotificationOptionApplicationUpdateAvailable": "Friss\u00edt\u00e9s el\u00e9rhet\u0151",
+ "NotificationOptionApplicationUpdateInstalled": "Program friss\u00edt\u00e9s telep\u00edtve",
+ "NotificationOptionPluginUpdateInstalled": "B\u0151v\u00edtm\u00e9ny friss\u00edt\u00e9s telep\u00edtve",
+ "NotificationOptionPluginInstalled": "B\u0151v\u00edtm\u00e9ny telep\u00edtve",
+ "NotificationOptionPluginUninstalled": "B\u0151v\u00edtm\u00e9ny elt\u00e1vol\u00edtva",
+ "NotificationOptionVideoPlayback": "Vide\u00f3 elind\u00edtva",
+ "NotificationOptionAudioPlayback": "Zene elind\u00edtva",
+ "NotificationOptionGamePlayback": "J\u00e1t\u00e9k elind\u00edtva",
+ "NotificationOptionVideoPlaybackStopped": "Vide\u00f3 meg\u00e1ll\u00edtva",
+ "NotificationOptionAudioPlaybackStopped": "Zene meg\u00e1ll\u00edtva",
+ "NotificationOptionGamePlaybackStopped": "J\u00e1t\u00e9k meg\u00e1ll\u00edtva",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Telep\u00edt\u00e9si hiba",
+ "NotificationOptionNewLibraryContent": "\u00daj tartalom hozz\u00e1adva",
+ "NotificationOptionNewLibraryContentMultiple": "\u00daj tartalom hozz\u00e1adva (t\u00f6bbsz\u00f6r\u00f6s)",
+ "NotificationOptionCameraImageUploaded": "Kamera k\u00e9p felt\u00f6ltve",
+ "NotificationOptionUserLockedOut": "Felhaszn\u00e1l\u00f3 tiltva",
+ "NotificationOptionServerRestartRequired": "\u00dajraind\u00edt\u00e1s sz\u00fcks\u00e9ges",
+ "ViewTypePlaylists": "Lej\u00e1tsz\u00e1si list\u00e1k",
+ "ViewTypeMovies": "Filmek",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "J\u00e1t\u00e9kok",
+ "ViewTypeMusic": "Zene",
+ "ViewTypeMusicGenres": "M\u0171fajok",
+ "ViewTypeMusicArtists": "M\u0171v\u00e9szek",
+ "ViewTypeBoxSets": "Gy\u0171jtem\u00e9nyek",
+ "ViewTypeChannels": "Csatorn\u00e1k",
+ "ViewTypeLiveTV": "\u00c9l\u0151 TV",
+ "ViewTypeLiveTvNowPlaying": "Most J\u00e1tszott",
+ "ViewTypeLatestGames": "Leg\u00fajabb J\u00e1t\u00e9kok",
+ "ViewTypeRecentlyPlayedGames": "Legut\u00f3bb J\u00e1tszott",
+ "ViewTypeGameFavorites": "Kedvencek",
+ "ViewTypeGameSystems": "J\u00e1t\u00e9k Rendszer",
+ "ViewTypeGameGenres": "M\u0171fajok",
+ "ViewTypeTvResume": "Folytat\u00e1s",
+ "ViewTypeTvNextUp": "K\u00f6vetkez\u0151",
+ "ViewTypeTvLatest": "Leg\u00fajabb",
+ "ViewTypeTvShowSeries": "Sorozat",
+ "ViewTypeTvGenres": "M\u0171fajok",
+ "ViewTypeTvFavoriteSeries": "Kedvenc Sorozat",
+ "ViewTypeTvFavoriteEpisodes": "Kedvenc R\u00e9szek",
+ "ViewTypeMovieResume": "Folytat\u00e1s",
+ "ViewTypeMovieLatest": "Leg\u00fajabb",
+ "ViewTypeMovieMovies": "Filmek",
+ "ViewTypeMovieCollections": "Gy\u0171jtem\u00e9nyek",
+ "ViewTypeMovieFavorites": "Kedvencek",
+ "ViewTypeMovieGenres": "M\u0171fajok",
+ "ViewTypeMusicLatest": "Leg\u00fajabb",
+ "ViewTypeMusicPlaylists": "Lej\u00e1tsz\u00e1si list\u00e1k",
+ "ViewTypeMusicAlbums": "Albumok",
+ "ViewTypeMusicAlbumArtists": "Album El\u0151ad\u00f3k",
+ "HeaderOtherDisplaySettings": "Megjelen\u00edt\u00e9si Be\u00e1ll\u00edt\u00e1sok",
+ "ViewTypeMusicSongs": "Dalok",
+ "ViewTypeMusicFavorites": "Kedvencek",
+ "ViewTypeMusicFavoriteAlbums": "Kedvenc Albumok",
+ "ViewTypeMusicFavoriteArtists": "Kedvenc M\u0171v\u00e9szek",
+ "ViewTypeMusicFavoriteSongs": "Kedvenc Dalok",
+ "ViewTypeFolders": "K\u00f6nyvt\u00e1rak",
+ "ViewTypeLiveTvRecordingGroups": "Felv\u00e9telek",
+ "ViewTypeLiveTvChannels": "Csatorn\u00e1k",
+ "ScheduledTaskFailedWithName": "{0} hiba",
+ "LabelRunningTimeValue": "Fut\u00e1si id\u0151: {0}",
+ "ScheduledTaskStartedWithName": "{0} elkezdve",
+ "VersionNumber": "Verzi\u00f3 {0}",
+ "PluginInstalledWithName": "{0} telep\u00edtve",
+ "PluginUpdatedWithName": "{0} friss\u00edtve",
+ "PluginUninstalledWithName": "{0} elt\u00e1vol\u00edtva",
+ "ItemAddedWithName": "{0} k\u00f6nyvt\u00e1rhoz adva",
+ "ItemRemovedWithName": "{0} t\u00f6r\u00f6lve a k\u00f6nyvt\u00e1rb\u00f3l",
+ "LabelIpAddressValue": "Ip c\u00edm: {0}",
+ "DeviceOnlineWithName": "{0} kapcsol\u00f3dva",
+ "UserOnlineFromDevice": "{0} akt\u00edv err\u0151l {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Felirat let\u00f6lt\u00e9se ehhez {0}",
+ "UserConfigurationUpdatedWithName": "A k\u00f6vetkez\u0151 felhaszn\u00e1l\u00f3 be\u00e1ll\u00edt\u00e1sai friss\u00edtve {0}",
+ "UserCreatedWithName": "Felhaszn\u00e1l\u00f3 {0} l\u00e9trehozva",
+ "UserPasswordChangedWithName": "Jelsz\u00f3 m\u00f3dos\u00edtva ennek a felhaszn\u00e1l\u00f3nak {0}",
+ "UserDeletedWithName": "Felhaszn\u00e1l\u00f3 {0} t\u00f6r\u00f6lve",
+ "MessageServerConfigurationUpdated": "Szerver be\u00e1ll\u00edt\u00e1sok friss\u00edtve",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server friss\u00edtve",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} sz\u00e9tkapcsolt",
+ "UserLockedOutWithName": "A k\u00f6vetkez\u0151 felhaszn\u00e1l\u00f3 tiltva {0}",
+ "UserOfflineFromDevice": "{0} kil\u00e9pett innen {1}",
+ "UserStartedPlayingItemWithValues": "{0} megkezdte j\u00e1tszani a(z) {1}",
+ "UserStoppedPlayingItemWithValues": "{0} befejezte a(z) {1}",
+ "SubtitleDownloadFailureForItem": "Nem siker\u00fcl a felirat let\u00f6lt\u00e9s ehhez {0}",
+ "HeaderUnidentified": "Azonos\u00edtatlan",
+ "HeaderImagePrimary": "Els\u0151dleges",
+ "HeaderImageBackdrop": "H\u00e1tt\u00e9r",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Felhaszn\u00e1l\u00f3 K\u00e9p",
+ "HeaderOverview": "\u00c1ttekint\u00e9s",
+ "HeaderShortOverview": "R\u00f6vid \u00c1ttekint\u00e9s",
+ "HeaderType": "T\u00edpus",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Felhaszn\u00e1l\u00f3",
+ "HeaderName": "N\u00e9v",
+ "HeaderDate": "D\u00e1tum",
+ "HeaderPremiereDate": "Megjelen\u00e9s D\u00e1tuma",
+ "HeaderDateAdded": "Hozz\u00e1adva",
+ "HeaderReleaseDate": "Megjelen\u00e9s d\u00e1tuma",
+ "HeaderRuntime": "J\u00e1t\u00e9kid\u0151",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "\u00c9vad",
+ "HeaderSeasonNumber": "\u00c9vad sz\u00e1ma",
+ "HeaderSeries": "Sorozatok:",
+ "HeaderNetwork": "H\u00e1l\u00f3zat",
+ "HeaderYear": "\u00c9v:",
+ "HeaderYears": "\u00c9v:",
+ "HeaderParentalRating": "Korhat\u00e1r besorol\u00e1s",
+ "HeaderCommunityRating": "K\u00f6z\u00f6ss\u00e9gi \u00e9rt\u00e9kel\u00e9s",
+ "HeaderTrailers": "El\u0151zetesek",
+ "HeaderSpecials": "Speci\u00e1lis",
+ "HeaderGameSystems": "J\u00e1t\u00e9k Rendszer",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albumok",
+ "HeaderDisc": "Lemez",
+ "HeaderTrack": "S\u00e1v",
+ "HeaderAudio": "Audi\u00f3",
+ "HeaderVideo": "Vide\u00f3",
+ "HeaderEmbeddedImage": "Be\u00e1gyazott k\u00e9p",
+ "HeaderResolution": "Felbont\u00e1s",
+ "HeaderSubtitles": "Feliratok",
+ "HeaderGenres": "M\u0171fajok",
+ "HeaderCountries": "Orsz\u00e1gok",
+ "HeaderStatus": "\u00c1llapot",
+ "HeaderTracks": "S\u00e1vok",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Z\u00e1rt",
+ "HeaderStudios": "St\u00fadi\u00f3k",
+ "HeaderActor": "Sz\u00edn\u00e9szek",
+ "HeaderComposer": "Zeneszerz\u0151k",
+ "HeaderDirector": "Rendez\u0151k",
+ "HeaderGuestStar": "Vend\u00e9g szt\u00e1r",
+ "HeaderProducer": "Producerek",
+ "HeaderWriter": "\u00cdr\u00f3k",
+ "HeaderParentalRatings": "Korhat\u00e1r besorol\u00e1s",
+ "HeaderCommunityRatings": "K\u00f6z\u00f6ss\u00e9gi \u00e9rt\u00e9kel\u00e9sek",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
new file mode 100644
index 000000000..8d64b63c4
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Silahkan menunggu sementara database Emby Server anda diupgrade. {0}% selesai.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Mewarisi",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Keluar",
+ "LabelVisitCommunity": "Kunjungi Komunitas",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Dokumentasi Api",
+ "LabelDeveloperResources": "Sumber daya Pengembang",
+ "LabelBrowseLibrary": "Telusuri Pustaka",
+ "LabelConfigureServer": "Konfigurasi Emby",
+ "LabelRestartServer": "Hidupkan ulang Server",
+ "CategorySync": "Singkron",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
new file mode 100644
index 000000000..d2d697c3e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+ "UserDownloadingItemWithValues": "{0} sta scaricando {1}",
+ "FolderTypeMixed": "contenuto misto",
+ "FolderTypeMovies": "Film",
+ "FolderTypeMusic": "Musica",
+ "FolderTypeAdultVideos": "Video per adulti",
+ "FolderTypePhotos": "Foto",
+ "FolderTypeMusicVideos": "Video musicali",
+ "FolderTypeHomeVideos": "Video personali",
+ "FolderTypeGames": "Giochi",
+ "FolderTypeBooks": "Libri",
+ "FolderTypeTvShows": "Tv",
+ "FolderTypeInherit": "ereditare",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "Persone",
+ "ValueSpecialEpisodeName": "Speciali - {0}",
+ "LabelChapterName": "Capitolo {0}",
+ "NameSeasonNumber": "Stagione {0}",
+ "LabelExit": "Esci",
+ "LabelVisitCommunity": "Visita la Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentazione Api",
+ "LabelDeveloperResources": "Risorse programmatori",
+ "LabelBrowseLibrary": "Esplora la libreria",
+ "LabelConfigureServer": "Configura Emby",
+ "LabelRestartServer": "Riavvia Server",
+ "CategorySync": "Sincronizza",
+ "CategoryUser": "Utente",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Applicazione",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin fallito",
+ "NotificationOptionApplicationUpdateAvailable": "Aggiornamento dell'applicazione disponibile",
+ "NotificationOptionApplicationUpdateInstalled": "Aggiornamento dell'applicazione installato",
+ "NotificationOptionPluginUpdateInstalled": "Aggiornamento del plugin installato",
+ "NotificationOptionPluginInstalled": "Plugin installato",
+ "NotificationOptionPluginUninstalled": "Plugin disinstallato",
+ "NotificationOptionVideoPlayback": "La riproduzione video \u00e8 iniziata",
+ "NotificationOptionAudioPlayback": "Riproduzione audio iniziata",
+ "NotificationOptionGamePlayback": "Gioco avviato",
+ "NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta",
+ "NotificationOptionAudioPlaybackStopped": "Audio Fermato",
+ "NotificationOptionGamePlaybackStopped": "Gioco Fermato",
+ "NotificationOptionTaskFailed": "Operazione pianificata fallita",
+ "NotificationOptionInstallationFailed": "Installazione fallita",
+ "NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto",
+ "NotificationOptionNewLibraryContentMultiple": "Nuovi contenuti aggiunti (multipli)",
+ "NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata",
+ "NotificationOptionUserLockedOut": "Utente bloccato",
+ "NotificationOptionServerRestartRequired": "Riavvio del server necessario",
+ "ViewTypePlaylists": "Playlist",
+ "ViewTypeMovies": "Film",
+ "ViewTypeTvShows": "Serie Tv",
+ "ViewTypeGames": "Giochi",
+ "ViewTypeMusic": "Musica",
+ "ViewTypeMusicGenres": "Generi",
+ "ViewTypeMusicArtists": "Artisti",
+ "ViewTypeBoxSets": "Collezioni",
+ "ViewTypeChannels": "Canali",
+ "ViewTypeLiveTV": "TV in diretta",
+ "ViewTypeLiveTvNowPlaying": "Ora in onda",
+ "ViewTypeLatestGames": "Ultimi Giorchi",
+ "ViewTypeRecentlyPlayedGames": "Guardato di recente",
+ "ViewTypeGameFavorites": "Preferiti",
+ "ViewTypeGameSystems": "Configurazione gioco",
+ "ViewTypeGameGenres": "Generi",
+ "ViewTypeTvResume": "Riprendi",
+ "ViewTypeTvNextUp": "Prossimi",
+ "ViewTypeTvLatest": "Ultimi",
+ "ViewTypeTvShowSeries": "Serie",
+ "ViewTypeTvGenres": "Generi",
+ "ViewTypeTvFavoriteSeries": "Serie Preferite",
+ "ViewTypeTvFavoriteEpisodes": "Episodi Preferiti",
+ "ViewTypeMovieResume": "Riprendi",
+ "ViewTypeMovieLatest": "Ultimi",
+ "ViewTypeMovieMovies": "Film",
+ "ViewTypeMovieCollections": "Collezioni",
+ "ViewTypeMovieFavorites": "Preferiti",
+ "ViewTypeMovieGenres": "Generi",
+ "ViewTypeMusicLatest": "Ultimi",
+ "ViewTypeMusicPlaylists": "Playlist",
+ "ViewTypeMusicAlbums": "Album",
+ "ViewTypeMusicAlbumArtists": "Album Artisti",
+ "HeaderOtherDisplaySettings": "Impostazioni Video",
+ "ViewTypeMusicSongs": "Canzoni",
+ "ViewTypeMusicFavorites": "Preferiti",
+ "ViewTypeMusicFavoriteAlbums": "Album preferiti",
+ "ViewTypeMusicFavoriteArtists": "Artisti preferiti",
+ "ViewTypeMusicFavoriteSongs": "Canzoni Preferite",
+ "ViewTypeFolders": "Cartelle",
+ "ViewTypeLiveTvRecordingGroups": "Registrazioni",
+ "ViewTypeLiveTvChannels": "canali",
+ "ScheduledTaskFailedWithName": "{0} Falliti",
+ "LabelRunningTimeValue": "Durata: {0}",
+ "ScheduledTaskStartedWithName": "{0} Avviati",
+ "VersionNumber": "Versione {0}",
+ "PluginInstalledWithName": "{0} sono stati Installati",
+ "PluginUpdatedWithName": "{0} sono stati aggiornati",
+ "PluginUninstalledWithName": "{0} non sono stati installati",
+ "ItemAddedWithName": "{0} aggiunti alla libreria",
+ "ItemRemovedWithName": "{0} rimossi dalla libreria",
+ "LabelIpAddressValue": "Indirizzo IP: {0}",
+ "DeviceOnlineWithName": "{0} \u00e8 connesso",
+ "UserOnlineFromDevice": "{0} \u00e8 online da {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Sottotitoli scaricati per {0}",
+ "UserConfigurationUpdatedWithName": "Configurazione utente \u00e8 stata aggiornata per {0}",
+ "UserCreatedWithName": "Utente {0} \u00e8 stato creato",
+ "UserPasswordChangedWithName": "Password utente cambiata per {0}",
+ "UserDeletedWithName": "Utente {0} \u00e8 stato cancellato",
+ "MessageServerConfigurationUpdated": "Configurazione server aggioprnata",
+ "MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} \u00e8 stata aggiornata",
+ "MessageApplicationUpdated": "Il Server Emby \u00e8 stato aggiornato",
+ "FailedLoginAttemptWithUserName": "Login fallito da {0}",
+ "AuthenticationSucceededWithUserName": "{0} Autenticati con successo",
+ "DeviceOfflineWithName": "{0} \u00e8 stato disconesso",
+ "UserLockedOutWithName": "L'utente {0} \u00e8 stato bloccato",
+ "UserOfflineFromDevice": "{0} \u00e8 stato disconesso da {1}",
+ "UserStartedPlayingItemWithValues": "{0} \u00e8 partito da {1}",
+ "UserStoppedPlayingItemWithValues": "{0} stoppato {1}",
+ "SubtitleDownloadFailureForItem": "Sottotitoli non scaricati per {0}",
+ "HeaderUnidentified": "Non identificata",
+ "HeaderImagePrimary": "Primaria",
+ "HeaderImageBackdrop": "Sfondo",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Immagine utente",
+ "HeaderOverview": "Panoramica",
+ "HeaderShortOverview": "breve panoramica",
+ "HeaderType": "Tipo",
+ "HeaderSeverity": "gravit\u00e0",
+ "HeaderUser": "Utente",
+ "HeaderName": "Nome",
+ "HeaderDate": "Data",
+ "HeaderPremiereDate": "Data della prima",
+ "HeaderDateAdded": "Aggiunto il",
+ "HeaderReleaseDate": "Data Rilascio",
+ "HeaderRuntime": "Durata",
+ "HeaderPlayCount": "Visto N\u00b0",
+ "HeaderSeason": "Stagione",
+ "HeaderSeasonNumber": "Stagione Numero",
+ "HeaderSeries": "Serie:",
+ "HeaderNetwork": "Rete",
+ "HeaderYear": "Anno:",
+ "HeaderYears": "Anni",
+ "HeaderParentalRating": "Valutazione parentale",
+ "HeaderCommunityRating": "Voto Comunit\u00e0",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Speciali",
+ "HeaderGameSystems": "Sistemi di gioco",
+ "HeaderPlayers": "Giocatori",
+ "HeaderAlbumArtists": "Album Artisti",
+ "HeaderAlbums": "Album",
+ "HeaderDisc": "Disco",
+ "HeaderTrack": "Traccia",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Immagine incorporata",
+ "HeaderResolution": "Risoluzione",
+ "HeaderSubtitles": "Sottotitoli",
+ "HeaderGenres": "Generi",
+ "HeaderCountries": "Paesi",
+ "HeaderStatus": "Stato",
+ "HeaderTracks": "Traccia",
+ "HeaderMusicArtist": "Musica artisti",
+ "HeaderLocked": "Bloccato",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Attori",
+ "HeaderComposer": "Compositori",
+ "HeaderDirector": "Registi",
+ "HeaderGuestStar": "Personaggi famosi",
+ "HeaderProducer": "Produttori",
+ "HeaderWriter": "Sceneggiatori",
+ "HeaderParentalRatings": "Valutazioni genitori",
+ "HeaderCommunityRatings": "Valutazione Comunity",
+ "StartupEmbyServerIsLoading": "Emby server si sta avviando. Riprova tra un po"
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json
new file mode 100644
index 000000000..93252c30b
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/kk.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Emby Server \u0434\u0435\u0440\u0435\u043a\u049b\u043e\u0440\u044b\u04a3\u044b\u0437\u0434\u044b\u04a3 \u0436\u0430\u04a3\u0493\u044b\u0440\u0442\u044b\u043b\u0443\u044b\u043d \u043a\u04af\u0442\u0435 \u0442\u04b1\u0440\u044b\u04a3\u044b\u0437. {0} % \u0430\u044f\u049b\u0442\u0430\u043b\u0434\u044b.",
+ "AppDeviceValues": "\u049a\u043e\u043b\u0434\u0430\u043d\u0431\u0430: {0}, \u049a\u04b1\u0440\u044b\u043b\u0493\u044b: {1}",
+ "UserDownloadingItemWithValues": "{0} \u043c\u044b\u043d\u0430\u043d\u044b \u0436\u04af\u043a\u0442\u0435\u043f \u0430\u043b\u0443\u0434\u0430: {1}",
+ "FolderTypeMixed": "\u0410\u0440\u0430\u043b\u0430\u0441 \u043c\u0430\u0437\u043c\u04b1\u043d",
+ "FolderTypeMovies": "\u041a\u0438\u043d\u043e",
+ "FolderTypeMusic": "\u041c\u0443\u0437\u044b\u043a\u0430",
+ "FolderTypeAdultVideos": "\u0415\u0440\u0435\u0441\u0435\u043a\u0442\u0456\u043a \u0431\u0435\u0439\u043d\u0435\u043b\u0435\u0440",
+ "FolderTypePhotos": "\u0424\u043e\u0442\u043e\u0441\u0443\u0440\u0435\u0442\u0442\u0435\u0440",
+ "FolderTypeMusicVideos": "\u041c\u0443\u0437\u044b\u043a\u0430\u043b\u044b\u049b \u0431\u0435\u0439\u043d\u0435\u043b\u0435\u0440",
+ "FolderTypeHomeVideos": "\u04ae\u0439 \u0431\u0435\u0439\u043d\u0435\u043b\u0435\u0440\u0456",
+ "FolderTypeGames": "\u041e\u0439\u044b\u043d\u0434\u0430\u0440",
+ "FolderTypeBooks": "\u041a\u0456\u0442\u0430\u043f\u0442\u0430\u0440",
+ "FolderTypeTvShows": "\u0422\u0414",
+ "FolderTypeInherit": "\u041c\u04b1\u0440\u0430\u0493\u0430 \u0438\u0435\u043b\u0435\u043d\u0443",
+ "HeaderCastCrew": "\u0421\u043e\u043c\u0434\u0430\u0443\u0448\u044b\u043b\u0430\u0440 \u043c\u0435\u043d \u0442\u04af\u0441\u0456\u0440\u0443\u0448\u0456\u043b\u0435\u0440",
+ "HeaderPeople": "\u0410\u0434\u0430\u043c\u0434\u0430\u0440",
+ "ValueSpecialEpisodeName": "\u0410\u0440\u043d\u0430\u0439\u044b - {0}",
+ "LabelChapterName": "{0}-\u0441\u0430\u0445\u043d\u0430",
+ "NameSeasonNumber": "{0}-\u0441\u0435\u0437\u043e\u043d",
+ "LabelExit": "\u0428\u044b\u0493\u0443",
+ "LabelVisitCommunity": "\u049a\u0430\u0443\u044b\u043c\u0434\u0430\u0441\u0442\u044b\u049b\u049b\u0430 \u0431\u0430\u0440\u0443",
+ "LabelGithub": "GitHub \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439\u0456",
+ "LabelApiDocumentation": "API \u049b\u04b1\u0436\u0430\u0442\u0442\u0430\u043c\u0430\u0441\u044b",
+ "LabelDeveloperResources": "\u0416\u0430\u0441\u0430\u049b\u0442\u0430\u0443\u0448\u044b \u043a\u04e9\u0437\u0434\u0435\u0440\u0456",
+ "LabelBrowseLibrary": "\u0422\u0430\u0441\u044b\u0493\u044b\u0448\u0445\u0430\u043d\u0430\u043d\u044b \u0448\u043e\u043b\u0443",
+ "LabelConfigureServer": "Emby \u0442\u0435\u04a3\u0448\u0435\u0443",
+ "LabelRestartServer": "\u0421\u0435\u0440\u0432\u0435\u0440\u0434\u0456 \u049b\u0430\u0439\u0442\u0430 \u0456\u0441\u043a\u0435 \u049b\u043e\u0441\u0443",
+ "CategorySync": "\u04ae\u043d\u0434\u0435\u0441\u0442\u0456\u0440\u0443",
+ "CategoryUser": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b",
+ "CategorySystem": "\u0416\u04af\u0439\u0435",
+ "CategoryApplication": "\u049a\u043e\u043b\u0434\u0430\u043d\u0431\u0430",
+ "CategoryPlugin": "\u041f\u043b\u0430\u0433\u0438\u043d",
+ "NotificationOptionPluginError": "\u041f\u043b\u0430\u0433\u0438\u043d \u0441\u04d9\u0442\u0441\u0456\u0437\u0434\u0456\u0433\u0456",
+ "NotificationOptionApplicationUpdateAvailable": "\u049a\u043e\u043b\u0434\u0430\u043d\u0431\u0430 \u0436\u0430\u04a3\u0430\u0440\u0442\u0443\u044b \u049b\u043e\u043b \u0436\u0435\u0442\u0456\u043c\u0434\u0456",
+ "NotificationOptionApplicationUpdateInstalled": "\u049a\u043e\u043b\u0434\u0430\u043d\u0431\u0430 \u0436\u0430\u04a3\u0430\u0440\u0442\u0443\u044b \u043e\u0440\u043d\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionPluginUpdateInstalled": "\u041f\u043b\u0430\u0433\u0438\u043d \u0436\u0430\u04a3\u0430\u0440\u0442\u0443\u044b \u043e\u0440\u043d\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionPluginInstalled": "\u041f\u043b\u0430\u0433\u0438\u043d \u043e\u0440\u043d\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionPluginUninstalled": "\u041f\u043b\u0430\u0433\u0438\u043d \u043e\u0440\u043d\u0430\u0442\u0443\u044b \u0431\u043e\u043b\u0434\u044b\u0440\u044b\u043b\u043c\u0430\u0434\u044b",
+ "NotificationOptionVideoPlayback": "\u0411\u0435\u0439\u043d\u0435 \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0431\u0430\u0441\u0442\u0430\u043b\u0434\u044b",
+ "NotificationOptionAudioPlayback": "\u0414\u044b\u0431\u044b\u0441 \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0431\u0430\u0441\u0442\u0430\u043b\u0434\u044b",
+ "NotificationOptionGamePlayback": "\u041e\u0439\u044b\u043d \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0431\u0430\u0441\u0442\u0430\u043b\u0434\u044b",
+ "NotificationOptionVideoPlaybackStopped": "\u0411\u0435\u0439\u043d\u0435 \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0442\u043e\u049b\u0442\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionAudioPlaybackStopped": "\u0414\u044b\u0431\u044b\u0441 \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0442\u043e\u049b\u0442\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionGamePlaybackStopped": "\u041e\u0439\u044b\u043d \u043e\u0439\u043d\u0430\u0442\u0443\u044b \u0442\u043e\u049b\u0442\u0430\u0442\u044b\u043b\u0434\u044b",
+ "NotificationOptionTaskFailed": "\u0416\u043e\u0441\u043f\u0430\u0440\u043b\u0430\u0493\u0430\u043d \u0442\u0430\u043f\u0441\u044b\u0440\u043c\u0430 \u0441\u04d9\u0442\u0441\u0456\u0437\u0434\u0456\u0433\u0456",
+ "NotificationOptionInstallationFailed": "\u041e\u0440\u043d\u0430\u0442\u0443 \u0441\u04d9\u0442\u0441\u0456\u0437\u0434\u0456\u0433\u0456",
+ "NotificationOptionNewLibraryContent": "\u0416\u0430\u04a3\u0430 \u043c\u0430\u0437\u043c\u04b1\u043d \u04af\u0441\u0442\u0435\u043b\u0433\u0435\u043d",
+ "NotificationOptionNewLibraryContentMultiple": "\u0416\u0430\u04a3\u0430 \u043c\u0430\u0437\u043c\u04b1\u043d \u049b\u043e\u0441\u044b\u043b\u0434\u044b (\u043a\u04e9\u043f\u0442\u0435\u0433\u0435\u043d)",
+ "NotificationOptionCameraImageUploaded": "\u041a\u0430\u043c\u0435\u0440\u0430\u0434\u0430\u043d \u0444\u043e\u0442\u043e\u0441\u0443\u0440\u0435\u0442 \u043a\u0435\u0440\u0456 \u049b\u043e\u0442\u0430\u0440\u044b\u043b\u0493\u0430\u043d",
+ "NotificationOptionUserLockedOut": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b \u049b\u04b1\u0440\u0441\u0430\u0443\u043b\u044b",
+ "NotificationOptionServerRestartRequired": "\u0421\u0435\u0440\u0432\u0435\u0440\u0434\u0456 \u049b\u0430\u0439\u0442\u0430 \u0456\u0441\u043a\u0435 \u049b\u043e\u0441\u0443 \u049b\u0430\u0436\u0435\u0442",
+ "ViewTypePlaylists": "\u041e\u0439\u043d\u0430\u0442\u0443 \u0442\u0456\u0437\u0456\u043c\u0434\u0435\u0440\u0456",
+ "ViewTypeMovies": "\u041a\u0438\u043d\u043e",
+ "ViewTypeTvShows": "\u0422\u0414",
+ "ViewTypeGames": "\u041e\u0439\u044b\u043d\u0434\u0430\u0440",
+ "ViewTypeMusic": "\u041c\u0443\u0437\u044b\u043a\u0430",
+ "ViewTypeMusicGenres": "\u0416\u0430\u043d\u0440\u043b\u0430\u0440",
+ "ViewTypeMusicArtists": "\u041e\u0440\u044b\u043d\u0434\u0430\u0443\u0448\u044b\u043b\u0430\u0440",
+ "ViewTypeBoxSets": "\u0416\u0438\u044b\u043d\u0442\u044b\u049b\u0442\u0430\u0440",
+ "ViewTypeChannels": "\u0410\u0440\u043d\u0430\u043b\u0430\u0440",
+ "ViewTypeLiveTV": "\u042d\u0444\u0438\u0440\u043b\u0456\u043a \u0422\u0414",
+ "ViewTypeLiveTvNowPlaying": "\u042d\u0444\u0438\u0440\u0434\u0435",
+ "ViewTypeLatestGames": "\u0415\u04a3 \u043a\u0435\u0439\u0456\u043d\u0433\u0456 \u043e\u0439\u044b\u043d\u0434\u0430\u0440",
+ "ViewTypeRecentlyPlayedGames": "\u0416\u0430\u049b\u044b\u043d\u0434\u0430 \u043e\u0439\u043d\u0430\u0442\u044b\u043b\u0493\u0430\u043d\u0434\u0430\u0440",
+ "ViewTypeGameFavorites": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b\u043b\u0430\u0440",
+ "ViewTypeGameSystems": "\u041e\u0439\u044b\u043d \u0436\u04af\u0439\u0435\u043b\u0435\u0440\u0456",
+ "ViewTypeGameGenres": "\u0416\u0430\u043d\u0440\u043b\u0430\u0440",
+ "ViewTypeTvResume": "\u0416\u0430\u043b\u0493\u0430\u0441\u0442\u044b\u0440\u043c\u0430\u043b\u044b",
+ "ViewTypeTvNextUp": "\u041a\u0435\u0437\u0435\u043a\u0442\u0456",
+ "ViewTypeTvLatest": "\u0415\u04a3 \u043a\u0435\u0439\u0456\u043d\u0433\u0456",
+ "ViewTypeTvShowSeries": "\u0422\u0435\u043b\u0435\u0445\u0438\u043a\u0430\u044f\u043b\u0430\u0440",
+ "ViewTypeTvGenres": "\u0416\u0430\u043d\u0440\u043b\u0430\u0440",
+ "ViewTypeTvFavoriteSeries": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b \u0442\u0435\u043b\u0435\u0445\u0438\u043a\u0430\u044f\u043b\u0430\u0440",
+ "ViewTypeTvFavoriteEpisodes": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b \u0431\u04e9\u043b\u0456\u043c\u0434\u0435\u0440",
+ "ViewTypeMovieResume": "\u0416\u0430\u043b\u0493\u0430\u0441\u0442\u044b\u0440\u043c\u0430\u043b\u044b",
+ "ViewTypeMovieLatest": "\u0415\u04a3 \u043a\u0435\u0439\u0456\u043d\u0433\u0456",
+ "ViewTypeMovieMovies": "\u0424\u0438\u043b\u044c\u043c\u0434\u0435\u0440",
+ "ViewTypeMovieCollections": "\u0416\u0438\u044b\u043d\u0442\u044b\u049b\u0442\u0430\u0440",
+ "ViewTypeMovieFavorites": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b\u043b\u0430\u0440",
+ "ViewTypeMovieGenres": "\u0416\u0430\u043d\u0440\u043b\u0430\u0440",
+ "ViewTypeMusicLatest": "\u0415\u04a3 \u043a\u0435\u0439\u0456\u043d\u0433\u0456",
+ "ViewTypeMusicPlaylists": "\u041e\u0439\u043d\u0430\u0442\u0443 \u0442\u0456\u0437\u0456\u043c\u0434\u0435\u0440\u0456",
+ "ViewTypeMusicAlbums": "\u0410\u043b\u044c\u0431\u043e\u043c\u0434\u0430\u0440",
+ "ViewTypeMusicAlbumArtists": "\u0410\u043b\u044c\u0431\u043e\u043c \u043e\u0440\u044b\u043d\u0434\u0430\u0443\u0448\u044b\u043b\u0430\u0440\u044b",
+ "HeaderOtherDisplaySettings": "\u0411\u0435\u0439\u043d\u0435\u043b\u0435\u0443 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043b\u0435\u0440\u0456",
+ "ViewTypeMusicSongs": "\u04d8\u0443\u0435\u043d\u0434\u0435\u0440",
+ "ViewTypeMusicFavorites": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b\u043b\u0430\u0440",
+ "ViewTypeMusicFavoriteAlbums": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b \u0430\u043b\u044c\u0431\u043e\u043c\u0434\u0430\u0440",
+ "ViewTypeMusicFavoriteArtists": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b \u043e\u0440\u044b\u043d\u0434\u0430\u0443\u0448\u044b\u043b\u0430\u0440",
+ "ViewTypeMusicFavoriteSongs": "\u0422\u0430\u04a3\u0434\u0430\u0443\u043b\u044b \u04d9\u0443\u0435\u043d\u0434\u0435\u0440",
+ "ViewTypeFolders": "\u049a\u0430\u043b\u0442\u0430\u043b\u0430\u0440",
+ "ViewTypeLiveTvRecordingGroups": "\u0416\u0430\u0437\u0431\u0430\u043b\u0430\u0440",
+ "ViewTypeLiveTvChannels": "\u0410\u0440\u043d\u0430\u043b\u0430\u0440",
+ "ScheduledTaskFailedWithName": "{0} \u0441\u04d9\u0442\u0441\u0456\u0437",
+ "LabelRunningTimeValue": "\u0406\u0441\u043a\u0435 \u049b\u043e\u0441\u044b\u043b\u0443 \u0443\u0430\u049b\u044b\u0442\u044b: {0}",
+ "ScheduledTaskStartedWithName": "{0} \u0456\u0441\u043a\u0435 \u049b\u043e\u0441\u044b\u043b\u0434\u044b",
+ "VersionNumber": "\u041d\u04b1\u0441\u049b\u0430\u0441\u044b: {0}",
+ "PluginInstalledWithName": "{0} \u043e\u0440\u043d\u0430\u0442\u044b\u043b\u0434\u044b",
+ "PluginUpdatedWithName": "{0} \u0436\u0430\u04a3\u0430\u0440\u0442\u044b\u043b\u0434\u044b",
+ "PluginUninstalledWithName": "{0} \u0436\u043e\u0439\u044b\u043b\u0434\u044b",
+ "ItemAddedWithName": "{0} (\u0442\u0430\u0441\u044b\u0493\u044b\u0448\u0445\u0430\u043d\u0430\u0493\u0430 \u04af\u0441\u0442\u0435\u043b\u0456\u043d\u0434\u0456)",
+ "ItemRemovedWithName": "{0} (\u0442\u0430\u0441\u044b\u0493\u044b\u0448\u0445\u0430\u043d\u0430\u0434\u0430\u043d \u0430\u043b\u0430\u0441\u0442\u0430\u043b\u0434\u044b)",
+ "LabelIpAddressValue": "IP \u043c\u0435\u043a\u0435\u043d\u0436\u0430\u0439\u044b: {0}",
+ "DeviceOnlineWithName": "{0} \u049b\u043e\u0441\u044b\u043b\u0493\u0430\u043d",
+ "UserOnlineFromDevice": "{0} - {1} \u0430\u0440\u049b\u044b\u043b\u044b \u049b\u043e\u0441\u044b\u043b\u0493\u0430\u043d",
+ "ProviderValue": "\u0416\u0435\u0442\u043a\u0456\u0437\u0443\u0448\u0456: {0}",
+ "SubtitlesDownloadedForItem": "\u0421\u0443\u0431\u0442\u0438\u0442\u0440\u043b\u0435\u0440 {0} \u04af\u0448\u0456\u043d \u0436\u04af\u043a\u0442\u0435\u043b\u0456\u043f \u0430\u043b\u044b\u043d\u0434\u044b",
+ "UserConfigurationUpdatedWithName": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b {0} \u04af\u0448\u0456\u043d \u0442\u0435\u04a3\u0448\u0435\u043b\u0456\u043c \u0436\u0430\u04a3\u0430\u0440\u0442\u044b\u043b\u0434\u044b",
+ "UserCreatedWithName": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b {0} \u0436\u0430\u0441\u0430\u043b\u0493\u0430\u043d",
+ "UserPasswordChangedWithName": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b {0} \u04af\u0448\u0456\u043d \u049b\u04b1\u043f\u0438\u044f \u0441\u04e9\u0437 \u04e9\u0437\u0433\u0435\u0440\u0442\u0456\u043b\u0434\u0456",
+ "UserDeletedWithName": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b {0} \u0436\u043e\u0439\u044b\u043b\u0493\u0430\u043d",
+ "MessageServerConfigurationUpdated": "\u0421\u0435\u0440\u0432\u0435\u0440 \u0442\u0435\u04a3\u0448\u0435\u043b\u0456\u043c\u0456 \u0436\u0430\u04a3\u0430\u0440\u0442\u044b\u043b\u0434\u044b",
+ "MessageNamedServerConfigurationUpdatedWithValue": "\u0421\u0435\u0440\u0432\u0435\u0440 \u0442\u0435\u04a3\u0448\u0435\u043b\u0456\u043c\u0456 ({0} \u0431\u04e9\u043b\u0456\u043c\u0456) \u0436\u0430\u04a3\u0430\u0440\u0442\u044b\u043b\u0434\u044b",
+ "MessageApplicationUpdated": "Emby Server \u0436\u0430\u04a3\u0430\u0440\u0442\u044b\u043b\u0434\u044b.",
+ "FailedLoginAttemptWithUserName": "{0} \u043a\u0456\u0440\u0443 \u04d9\u0440\u0435\u043a\u0435\u0442\u0456 \u0441\u04d9\u0442\u0441\u0456\u0437",
+ "AuthenticationSucceededWithUserName": "{0} \u0442\u04af\u043f\u043d\u04b1\u0441\u049b\u0430\u043b\u044b\u0493\u044b\u043d \u0440\u0430\u0441\u0442\u0430\u043b\u0443\u044b \u0441\u04d9\u0442\u0442\u0456",
+ "DeviceOfflineWithName": "{0} \u0430\u0436\u044b\u0440\u0430\u0442\u044b\u043b\u0493\u0430\u043d",
+ "UserLockedOutWithName": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b {0} \u049b\u04b1\u0440\u0441\u0430\u0443\u043b\u044b",
+ "UserOfflineFromDevice": "{0} - {1} \u0430\u0440\u049b\u044b\u043b\u044b \u0430\u0436\u044b\u0440\u0430\u0442\u044b\u043b\u0493\u0430\u043d",
+ "UserStartedPlayingItemWithValues": "{0} - {1} \u043e\u0439\u043d\u0430\u0442\u0443\u044b\u043d \u0431\u0430\u0441\u0442\u0430\u0434\u044b",
+ "UserStoppedPlayingItemWithValues": "{0} - {1} \u043e\u0439\u043d\u0430\u0442\u0443\u044b\u043d \u0442\u043e\u049b\u0442\u0430\u0442\u0442\u044b",
+ "SubtitleDownloadFailureForItem": "\u0421\u0443\u0431\u0442\u0438\u0442\u0440\u043b\u0435\u0440 {0} \u04af\u0448\u0456\u043d \u0436\u04af\u043a\u0442\u0435\u043b\u0456\u043f \u0430\u043b\u044b\u043d\u0443\u044b \u0441\u04d9\u0442\u0441\u0456\u0437",
+ "HeaderUnidentified": "\u0410\u043d\u044b\u049b\u0442\u0430\u043b\u043c\u0430\u0493\u0430\u043d",
+ "HeaderImagePrimary": "\u041d\u0435\u0433\u0456\u0437\u0433\u0456",
+ "HeaderImageBackdrop": "\u0410\u0440\u0442\u049b\u044b \u0441\u0443\u0440\u0435\u0442",
+ "HeaderImageLogo": "\u041b\u043e\u0433\u043e\u0442\u0438\u043f",
+ "HeaderUserPrimaryImage": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b \u0441\u0443\u0440\u0435\u0442\u0456",
+ "HeaderOverview": "\u0416\u0430\u043b\u043f\u044b \u0448\u043e\u043b\u0443",
+ "HeaderShortOverview": "\u049a\u044b\u0441\u049b\u0430\u0448\u0430 \u0448\u043e\u043b\u0443",
+ "HeaderType": "\u0422\u04af\u0440\u0456",
+ "HeaderSeverity": "\u049a\u0438\u044b\u043d\u0434\u044b\u0493\u044b",
+ "HeaderUser": "\u041f\u0430\u0439\u0434\u0430\u043b\u0430\u043d\u0443\u0448\u044b",
+ "HeaderName": "\u0410\u0442\u044b",
+ "HeaderDate": "\u041a\u04af\u043d\u0456",
+ "HeaderPremiereDate": "\u0422\u04b1\u0441\u0430\u0443\u043a\u0435\u0441\u0435\u0440 \u043a\u04af\u043d\u0456",
+ "HeaderDateAdded": "\u04ae\u0441\u0442\u0435\u043b\u0433\u0435\u043d \u043a\u04af\u043d\u0456",
+ "HeaderReleaseDate": "\u0428\u044b\u0493\u0430\u0440\u0443 \u043a\u04af\u043d\u0456",
+ "HeaderRuntime": "\u04b0\u0437\u0430\u049b\u0442\u044b\u0493\u044b",
+ "HeaderPlayCount": "\u041e\u0439\u043d\u0430\u0442\u0443 \u0435\u0441\u0435\u0431\u0456",
+ "HeaderSeason": "\u041c\u0430\u0443\u0441\u044b\u043c",
+ "HeaderSeasonNumber": "\u041c\u0430\u0443\u0441\u044b\u043c \u043d\u04e9\u043c\u0456\u0440\u0456",
+ "HeaderSeries": "\u0422\u0435\u043b\u0435\u0445\u0438\u043a\u0430\u044f\u043b\u0430\u0440",
+ "HeaderNetwork": "\u0422\u0435\u043b\u0435\u0436\u0435\u043b\u0456",
+ "HeaderYear": "\u0416\u044b\u043b:",
+ "HeaderYears": "\u0416\u044b\u043b\u0434\u0430\u0440:",
+ "HeaderParentalRating": "\u0416\u0430\u0441\u0442\u0430\u0441 \u0441\u0430\u043d\u0430\u0442\u044b",
+ "HeaderCommunityRating": "\u049a\u0430\u0443\u044b\u043c \u0431\u0430\u0493\u0430\u043b\u0430\u0443\u044b",
+ "HeaderTrailers": "\u0422\u0440\u0435\u0439\u043b\u0435\u0440\u043b\u0435\u0440",
+ "HeaderSpecials": "\u0410\u0440\u043d\u0430\u0439\u044b \u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b\u0434\u0430\u0440",
+ "HeaderGameSystems": "\u041e\u0439\u044b\u043d \u0436\u04af\u0439\u0435\u043b\u0435\u0440\u0456",
+ "HeaderPlayers": "\u041e\u0439\u044b\u043d\u0448\u044b\u043b\u0430\u0440:",
+ "HeaderAlbumArtists": "\u0410\u043b\u044c\u0431\u043e\u043c \u043e\u0440\u044b\u043d\u0434\u0430\u0443\u0448\u044b\u043b\u0430\u0440\u044b",
+ "HeaderAlbums": "\u0410\u043b\u044c\u0431\u043e\u043c\u0434\u0430\u0440",
+ "HeaderDisc": "\u0414\u0438\u0441\u043a\u0456",
+ "HeaderTrack": "\u0416\u043e\u043b\u0448\u044b\u049b",
+ "HeaderAudio": "\u0414\u044b\u0431\u044b\u0441",
+ "HeaderVideo": "\u0411\u0435\u0439\u043d\u0435",
+ "HeaderEmbeddedImage": "\u0415\u043d\u0434\u0456\u0440\u0456\u043b\u0433\u0435\u043d \u0441\u0443\u0440\u0435\u0442",
+ "HeaderResolution": "\u0410\u0436\u044b\u0440\u0430\u0442\u044b\u043c\u0434\u044b\u043b\u044b\u0493\u044b",
+ "HeaderSubtitles": "\u0421\u0443\u0431\u0442\u0438\u0442\u0440\u043b\u0435\u0440",
+ "HeaderGenres": "\u0416\u0430\u043d\u0440\u043b\u0430\u0440",
+ "HeaderCountries": "\u0415\u043b\u0434\u0435\u0440",
+ "HeaderStatus": "\u041a\u04af\u0439",
+ "HeaderTracks": "\u0416\u043e\u043b\u0448\u044b\u049b\u0442\u0430\u0440",
+ "HeaderMusicArtist": "\u041c\u0443\u0437\u044b\u043a\u0430 \u043e\u0440\u044b\u043d\u0434\u0430\u0443\u0448\u044b",
+ "HeaderLocked": "\u049a\u04b1\u043b\u044b\u043f\u0442\u0430\u043b\u0493\u0430\u043d",
+ "HeaderStudios": "\u0421\u0442\u0443\u0434\u0438\u044f\u043b\u0430\u0440",
+ "HeaderActor": "\u0410\u043a\u0442\u0435\u0440\u043b\u0435\u0440",
+ "HeaderComposer": "\u041a\u043e\u043c\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u043b\u0430\u0440",
+ "HeaderDirector": "\u0420\u0435\u0436\u0438\u0441\u0441\u0435\u0440\u043b\u0435\u0440",
+ "HeaderGuestStar": "\u0428\u0430\u049b\u044b\u0440\u044b\u043b\u0493\u0430\u043d \u0430\u043a\u0442\u0435\u0440",
+ "HeaderProducer": "\u041f\u0440\u043e\u0434\u044e\u0441\u0435\u0440\u043b\u0435\u0440",
+ "HeaderWriter": "\u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u0448\u0456\u043b\u0435\u0440",
+ "HeaderParentalRatings": "\u0416\u0430\u0441\u0442\u0430\u0441 \u0441\u0430\u043d\u0430\u0442\u0442\u0430\u0440",
+ "HeaderCommunityRatings": "\u049a\u0430\u0443\u044b\u043c \u0431\u0430\u0493\u0430\u043b\u0430\u0443\u043b\u0430\u0440\u044b",
+ "StartupEmbyServerIsLoading": "Emby Server \u0436\u04af\u043a\u0442\u0435\u043b\u0443\u0434\u0435. \u04d8\u0440\u0435\u043a\u0435\u0442\u0442\u0456 \u043a\u04e9\u043f \u04b1\u0437\u0430\u043c\u0430\u0439 \u049b\u0430\u0439\u0442\u0430\u043b\u0430\u04a3\u044b\u0437."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
new file mode 100644
index 000000000..834ccc17b
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "\uc571: {0}, \uc7a5\uce58: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\ud63c\ud569 \ucf58\ud150\ud2b8",
+ "FolderTypeMovies": "\uc601\ud654",
+ "FolderTypeMusic": "\uc74c\uc545",
+ "FolderTypeAdultVideos": "\uc131\uc778 \ube44\ub514\uc624",
+ "FolderTypePhotos": "\uc0ac\uc9c4",
+ "FolderTypeMusicVideos": "\ubba4\uc9c1 \ube44\ub514\uc624",
+ "FolderTypeHomeVideos": "\ud648 \ube44\ub514\uc624",
+ "FolderTypeGames": "\uac8c\uc784",
+ "FolderTypeBooks": "\ucc45",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "\ubc30\uc5ed \ubc0f \uc81c\uc791\uc9c4",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "\ucc55\ud130 {0}",
+ "NameSeasonNumber": "\uc2dc\uc98c {0}",
+ "LabelExit": "\uc885\ub8cc",
+ "LabelVisitCommunity": "\ucee4\ubba4\ub2c8\ud2f0 \ubc29\ubb38",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api \ubb38\uc11c",
+ "LabelDeveloperResources": "\uac1c\ubc1c\uc790 \ub9ac\uc18c\uc2a4",
+ "LabelBrowseLibrary": "\ub77c\uc774\ube0c\ub7ec\ub9ac \ud0d0\uc0c9",
+ "LabelConfigureServer": "Emby \uc124\uc815",
+ "LabelRestartServer": "\uc11c\ubc84 \uc7ac\uc2dc\ub3d9",
+ "CategorySync": "\ub3d9\uae30\ud654",
+ "CategoryUser": "\uc0ac\uc6a9\uc790",
+ "CategorySystem": "\uc2dc\uc2a4\ud15c",
+ "CategoryApplication": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158",
+ "CategoryPlugin": "\ud50c\ub7ec\uadf8\uc778",
+ "NotificationOptionPluginError": "\ud50c\ub7ec\uadf8\uc778 \uc2e4\ud328",
+ "NotificationOptionApplicationUpdateAvailable": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \uc5c5\ub370\uc774\ud2b8 \uc0ac\uc6a9 \uac00\ub2a5",
+ "NotificationOptionApplicationUpdateInstalled": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \uc5c5\ub370\uc774\ud2b8 \uc124\uce58\ub428",
+ "NotificationOptionPluginUpdateInstalled": "\ud50c\ub7ec\uadf8\uc778 \uc5c5\ub370\uc774\ud2b8 \uc124\uce58\ub428",
+ "NotificationOptionPluginInstalled": "\ud50c\ub7ec\uadf8\uc778 \uc124\uce58\ub428",
+ "NotificationOptionPluginUninstalled": "\ud50c\ub7ec\uadf8\uc778 \uc124\uce58 \uc81c\uac70\ub428",
+ "NotificationOptionVideoPlayback": "\ube44\ub514\uc624 \uc7ac\uc0dd \uc2dc\uc791\ub428",
+ "NotificationOptionAudioPlayback": "\uc624\ub514\uc624 \uc7ac\uc0dd \uc2dc\uc791\ub428",
+ "NotificationOptionGamePlayback": "\uac8c\uc784 \ud50c\ub808\uc774 \uc9c0\uc791\ub428",
+ "NotificationOptionVideoPlaybackStopped": "\ube44\ub514\uc624 \uc7ac\uc0dd \uc911\uc9c0\ub428",
+ "NotificationOptionAudioPlaybackStopped": "\uc624\ub514\uc624 \uc7ac\uc0dd \uc911\uc9c0\ub428",
+ "NotificationOptionGamePlaybackStopped": "\uac8c\uc784 \ud50c\ub808\uc774 \uc911\uc9c0\ub428",
+ "NotificationOptionTaskFailed": "\uc608\uc57d \uc791\uc5c5 \uc2e4\ud328",
+ "NotificationOptionInstallationFailed": "\uc124\uce58 \uc2e4\ud328",
+ "NotificationOptionNewLibraryContent": "\uc0c8 \ucf58\ud150\ud2b8 \ucd94\uac00\ub428",
+ "NotificationOptionNewLibraryContentMultiple": "\uc0c8 \ucf58\ub374\ud2b8 \ucd94\uac00\ub428 (\ubcf5\uc218)",
+ "NotificationOptionCameraImageUploaded": "\uce74\uba54\ub77c \uc774\ubbf8\uc9c0 \uc5c5\ub85c\ub4dc\ub428",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "\uc11c\ubc84\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud558\uc5ec\uc57c \ud569\ub2c8\ub2e4",
+ "ViewTypePlaylists": "\uc7ac\uc0dd\ubaa9\ub85d",
+ "ViewTypeMovies": "\uc601\ud654",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "\uac8c\uc784",
+ "ViewTypeMusic": "\uc74c\uc545",
+ "ViewTypeMusicGenres": "\uc7a5\ub974",
+ "ViewTypeMusicArtists": "\uc544\ud2f0\uc2a4\ud2b8",
+ "ViewTypeBoxSets": "\uceec\ub809\uc158",
+ "ViewTypeChannels": "\ucc44\ub110",
+ "ViewTypeLiveTV": "TV \ubc29\uc1a1",
+ "ViewTypeLiveTvNowPlaying": "\uc9c0\uae08 \ubc29\uc1a1 \uc911",
+ "ViewTypeLatestGames": "\ucd5c\uadfc \uac8c\uc784",
+ "ViewTypeRecentlyPlayedGames": "\ucd5c\uadfc \ud50c\ub808\uc774",
+ "ViewTypeGameFavorites": "\uc990\uaca8\ucc3e\uae30",
+ "ViewTypeGameSystems": "\uac8c\uc784 \uc2dc\uc2a4\ud15c",
+ "ViewTypeGameGenres": "\uc7a5\ub974",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "\uc2dc\ub9ac\uc988",
+ "ViewTypeTvGenres": "\uc7a5\ub974",
+ "ViewTypeTvFavoriteSeries": "\uc88b\uc544\ud558\ub294 \uc2dc\ub9ac\uc988",
+ "ViewTypeTvFavoriteEpisodes": "\uc88b\uc544\ud558\ub294 \uc5d0\ud53c\uc18c\ub4dc",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "\uc601\ud654",
+ "ViewTypeMovieCollections": "\uceec\ub809\uc158",
+ "ViewTypeMovieFavorites": "\uc990\uaca8\ucc3e\uae30",
+ "ViewTypeMovieGenres": "\uc7a5\ub974",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "\uc7ac\uc0dd\ubaa9\ub85d",
+ "ViewTypeMusicAlbums": "\uc568\ubc94",
+ "ViewTypeMusicAlbumArtists": "\uc568\ubc94 \uc544\ud2f0\uc2a4\ud2b8",
+ "HeaderOtherDisplaySettings": "\ud654\uba74 \uc124\uc815",
+ "ViewTypeMusicSongs": "\ub178\ub798",
+ "ViewTypeMusicFavorites": "\uc990\uaca8\ucc3e\uae30",
+ "ViewTypeMusicFavoriteAlbums": "\uc88b\uc544\ud558\ub294 \uc568\ubc94",
+ "ViewTypeMusicFavoriteArtists": "\uc88b\uc544\ud558\ub294 \uc544\ud2f0\uc2a4\ud2b8",
+ "ViewTypeMusicFavoriteSongs": "\uc88b\uc544\ud558\ub294 \ub178\ub798",
+ "ViewTypeFolders": "\ud3f4\ub354",
+ "ViewTypeLiveTvRecordingGroups": "\ub179\ud654",
+ "ViewTypeLiveTvChannels": "\ucc44\ub110",
+ "ScheduledTaskFailedWithName": "{0} \uc2e4\ud328",
+ "LabelRunningTimeValue": "\uc0c1\uc601 \uc2dc\uac04: {0}",
+ "ScheduledTaskStartedWithName": "{0} \uc2dc\uc791\ub428",
+ "VersionNumber": "\ubc84\uc804 {0}",
+ "PluginInstalledWithName": "{0} \uc124\uce58\ub428",
+ "PluginUpdatedWithName": "{0} \uc5c5\ub370\uc774\ud2b8\ub428",
+ "PluginUninstalledWithName": "{0} \uc124\uce58 \uc81c\uac70\ub428",
+ "ItemAddedWithName": "\ub77c\uc774\ube0c\ub7ec\ub9ac\uc5d0 {0} \ucd94\uac00\ub428",
+ "ItemRemovedWithName": "\ub77c\uc774\ube0c\ub7ec\ub9ac\uc5d0\uc11c {0} \uc0ad\uc81c\ub428",
+ "LabelIpAddressValue": "IP \uc8fc\uc18c: {0}",
+ "DeviceOnlineWithName": "{0} \uc5f0\uacb0\ub428",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "\uc81c\uacf5\uc790: {0}",
+ "SubtitlesDownloadedForItem": "{0} \uc790\ub9c9 \ub2e4\uc6b4\ub85c\ub4dc\ub428",
+ "UserConfigurationUpdatedWithName": "{0} \uc0ac\uc6a9\uc790 \uc124\uc815\uc774 \uc5c5\ub370\uc774\ud2b8\ub428",
+ "UserCreatedWithName": "\uc0ac\uc6a9\uc790 {0} \uc0dd\uc131\ub428",
+ "UserPasswordChangedWithName": "\uc0ac\uc6a9\uc790 {0} \ube44\ubc00\ubc88\ud638 \ubcc0\uacbd\ub428",
+ "UserDeletedWithName": "\uc0ac\uc6a9\uc790 {0} \uc0ad\uc81c\ub428",
+ "MessageServerConfigurationUpdated": "\uc11c\ubc84 \ud658\uacbd \uc124\uc815 \uc5c5\ub370\uc774\ub4dc\ub428",
+ "MessageNamedServerConfigurationUpdatedWithValue": "\uc11c\ubc84 \ud658\uacbd \uc124\uc815 {0} \uc139\uc158 \uc5c5\ub370\uc774\ud2b8 \ub428",
+ "MessageApplicationUpdated": "Emby \uc11c\ubc84 \uc5c5\ub370\uc774\ud2b8\ub428",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} \uc5f0\uacb0 \ud574\uc81c\ub428",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{1} \uc5d0\uc11c {0} \uc5f0\uacb0 \ud574\uc81c\ub428",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "{0} \uc790\ub9c9 \ub2e4\uc6b4\ub85c\ub4dc \uc2e4\ud328",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "\ubc30\uacbd",
+ "HeaderImageLogo": "\ub85c\uace0",
+ "HeaderUserPrimaryImage": "\uc0ac\uc6a9\uc790 \uc774\ubbf8\uc9c0",
+ "HeaderOverview": "\uc904\uac70\ub9ac",
+ "HeaderShortOverview": "\uac04\ub7b5 \uc904\uac70\ub9ac",
+ "HeaderType": "Type",
+ "HeaderSeverity": "\uc2ec\uac01\ub3c4",
+ "HeaderUser": "\uc0ac\uc6a9\uc790",
+ "HeaderName": "Name",
+ "HeaderDate": "\ub0a0\uc9dc",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "\uac1c\ubd09\uc77c",
+ "HeaderRuntime": "\uc0c1\uc601 \uc2dc\uac04",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "\uc2dc\uc98c",
+ "HeaderSeasonNumber": "\uc2dc\uc98c \ubc88\ud638",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "\ub124\ud2b8\uc6cc\ud06c",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "\ucee4\ubba4\ub2c8\ud2f0 \ud3c9\uc810",
+ "HeaderTrailers": "\uc608\uace0\ud3b8",
+ "HeaderSpecials": "\uc2a4\ud398\uc15c",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "\uc568\ubc94",
+ "HeaderDisc": "\ub514\uc2a4\ud06c",
+ "HeaderTrack": "\ud2b8\ub799",
+ "HeaderAudio": "\uc624\ub514\uc624",
+ "HeaderVideo": "\ube44\ub514\uc624",
+ "HeaderEmbeddedImage": "\ub0b4\uc7a5 \uc774\ubbf8\uc9c0",
+ "HeaderResolution": "\ud574\uc0c1\ub3c4",
+ "HeaderSubtitles": "\uc790\ub9c9",
+ "HeaderGenres": "\uc7a5\ub974",
+ "HeaderCountries": "\uad6d\uac00",
+ "HeaderStatus": "\uc0c1\ud0dc",
+ "HeaderTracks": "\ud2b8\ub799",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "\uc7a0\uae40",
+ "HeaderStudios": "\uc2a4\ud29c\ub514\uc624",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "\uc790\ub140 \ubcf4\ud638 \ub4f1\uae09",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json
new file mode 100644
index 000000000..fe5eef894
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ms.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Tutup",
+ "LabelVisitCommunity": "Melawat Masyarakat",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Imbas Pengumpulan",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Restart Server",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
new file mode 100644
index 000000000..315d49b5f
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0} , Device: {1}",
+ "UserDownloadingItemWithValues": "{0} laster ned {1}",
+ "FolderTypeMixed": "Forskjellig innhold",
+ "FolderTypeMovies": "Filmer",
+ "FolderTypeMusic": "Musikk",
+ "FolderTypeAdultVideos": "Voksen-videoer",
+ "FolderTypePhotos": "Foto",
+ "FolderTypeMusicVideos": "Musikk-videoer",
+ "FolderTypeHomeVideos": "Hjemme-videoer",
+ "FolderTypeGames": "Spill",
+ "FolderTypeBooks": "B\u00f8ker",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Arve",
+ "HeaderCastCrew": "Mannskap",
+ "HeaderPeople": "Personer",
+ "ValueSpecialEpisodeName": "Spesiell - {0}",
+ "LabelChapterName": "Kapittel {0}",
+ "NameSeasonNumber": "Sesong {0}",
+ "LabelExit": "Avslutt",
+ "LabelVisitCommunity": "Bes\u00f8k oss",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "API-dokumentasjon",
+ "LabelDeveloperResources": "Ressurser for Utviklere",
+ "LabelBrowseLibrary": "Browse biblioteket",
+ "LabelConfigureServer": "Konfigurer Emby",
+ "LabelRestartServer": "Restart serveren",
+ "CategorySync": "Synk",
+ "CategoryUser": "Bruker",
+ "CategorySystem": "System",
+ "CategoryApplication": "Applikasjon",
+ "CategoryPlugin": "Programtillegg",
+ "NotificationOptionPluginError": "Programtillegg feilet",
+ "NotificationOptionApplicationUpdateAvailable": "Oppdatering tilgjengelig",
+ "NotificationOptionApplicationUpdateInstalled": "Oppdatering installert",
+ "NotificationOptionPluginUpdateInstalled": "Oppdatert programtillegg installert",
+ "NotificationOptionPluginInstalled": "Programtillegg installert",
+ "NotificationOptionPluginUninstalled": "Programtillegg er fjernet",
+ "NotificationOptionVideoPlayback": "Videoavspilling startet",
+ "NotificationOptionAudioPlayback": "Lydavspilling startet",
+ "NotificationOptionGamePlayback": "Spill startet",
+ "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet",
+ "NotificationOptionAudioPlaybackStopped": "Lydavspilling stoppet",
+ "NotificationOptionGamePlaybackStopped": "Spill stoppet",
+ "NotificationOptionTaskFailed": "Planlagt oppgave feilet",
+ "NotificationOptionInstallationFailed": "Installasjon feilet",
+ "NotificationOptionNewLibraryContent": "Nytt innhold er lagt til",
+ "NotificationOptionNewLibraryContentMultiple": "Nytt innhold lagt til (flere)",
+ "NotificationOptionCameraImageUploaded": "Bilde fra kamera lastet opp",
+ "NotificationOptionUserLockedOut": "Bruker er utestengt",
+ "NotificationOptionServerRestartRequired": "Server m\u00e5 startes p\u00e5 nytt",
+ "ViewTypePlaylists": "Spillelister",
+ "ViewTypeMovies": "Filmer",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Spill",
+ "ViewTypeMusic": "Musikk",
+ "ViewTypeMusicGenres": "Sjangere",
+ "ViewTypeMusicArtists": "Artist",
+ "ViewTypeBoxSets": "Samlinger",
+ "ViewTypeChannels": "Kanaler",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Sendes n\u00e5",
+ "ViewTypeLatestGames": "Siste spill",
+ "ViewTypeRecentlyPlayedGames": "Nylig spilt",
+ "ViewTypeGameFavorites": "Favoritter",
+ "ViewTypeGameSystems": "Spillsystemer",
+ "ViewTypeGameGenres": "Sjangere",
+ "ViewTypeTvResume": "Fortsette",
+ "ViewTypeTvNextUp": "Neste",
+ "ViewTypeTvLatest": "Siste",
+ "ViewTypeTvShowSeries": "Serier",
+ "ViewTypeTvGenres": "Sjangere",
+ "ViewTypeTvFavoriteSeries": "Favoritt serier",
+ "ViewTypeTvFavoriteEpisodes": "Favoritt episoder",
+ "ViewTypeMovieResume": "Fortsette",
+ "ViewTypeMovieLatest": "Siste",
+ "ViewTypeMovieMovies": "Filmer",
+ "ViewTypeMovieCollections": "Samlinger",
+ "ViewTypeMovieFavorites": "Favoritter",
+ "ViewTypeMovieGenres": "Sjangere",
+ "ViewTypeMusicLatest": "Siste",
+ "ViewTypeMusicPlaylists": "Spillelister",
+ "ViewTypeMusicAlbums": "Albumer",
+ "ViewTypeMusicAlbumArtists": "Album artister",
+ "HeaderOtherDisplaySettings": "Visnings Innstillinger",
+ "ViewTypeMusicSongs": "Sanger",
+ "ViewTypeMusicFavorites": "Favoritter",
+ "ViewTypeMusicFavoriteAlbums": "Favorittalbumer",
+ "ViewTypeMusicFavoriteArtists": "Favorittartister",
+ "ViewTypeMusicFavoriteSongs": "Favorittsanger",
+ "ViewTypeFolders": "Mapper",
+ "ViewTypeLiveTvRecordingGroups": "Opptak",
+ "ViewTypeLiveTvChannels": "Kanaler",
+ "ScheduledTaskFailedWithName": "{0} feilet",
+ "LabelRunningTimeValue": "Spille tide: {0}",
+ "ScheduledTaskStartedWithName": "{0} startet",
+ "VersionNumber": "Versjon {0}",
+ "PluginInstalledWithName": "{0} ble installert",
+ "PluginUpdatedWithName": "{0} ble oppdatert",
+ "PluginUninstalledWithName": "{0} ble avinstallert",
+ "ItemAddedWithName": "{0} ble lagt til biblioteket",
+ "ItemRemovedWithName": "{0} ble fjernet fra biblioteket",
+ "LabelIpAddressValue": "Ip adresse: {0}",
+ "DeviceOnlineWithName": "{0} er tilkoblet",
+ "UserOnlineFromDevice": "{0} er online fra {1}",
+ "ProviderValue": "Tilbyder: {0}",
+ "SubtitlesDownloadedForItem": "Undertekster lastet ned for {0}",
+ "UserConfigurationUpdatedWithName": "Bruker konfigurasjon har blitt oppdatert for {0}",
+ "UserCreatedWithName": "Bruker {0} har blitt opprettet",
+ "UserPasswordChangedWithName": "Passord har blitt endret for bruker {0}",
+ "UserDeletedWithName": "Bruker {0} har blitt slettet",
+ "MessageServerConfigurationUpdated": "Server konfigurasjon har blitt oppdatert",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server konfigurasjon seksjon {0} har blitt oppdatert",
+ "MessageApplicationUpdated": "Emby server har blitt oppdatert",
+ "FailedLoginAttemptWithUserName": "P\u00e5loggingsfors\u00f8k feilet fra {0}",
+ "AuthenticationSucceededWithUserName": "{0} autentisert med suksess",
+ "DeviceOfflineWithName": "{0} har koblet fra",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} har koblet fra {1}",
+ "UserStartedPlayingItemWithValues": "{0} har startet avspilling av {1}",
+ "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling av {1}",
+ "SubtitleDownloadFailureForItem": "nedlasting av undertekster feilet for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Bruker",
+ "HeaderName": "Navn",
+ "HeaderDate": "Dato",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Utgivelsesdato",
+ "HeaderRuntime": "Spilletid",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Sesong",
+ "HeaderSeasonNumber": "Sesong nummer",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Nettverk",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Fellesskap anmeldelse",
+ "HeaderTrailers": "Trailere",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albumer",
+ "HeaderDisc": "Disk",
+ "HeaderTrack": "Spor",
+ "HeaderAudio": "Lyd",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "innebygd bilde",
+ "HeaderResolution": "Oppl\u00f8sning",
+ "HeaderSubtitles": "Undertekster",
+ "HeaderGenres": "Sjanger",
+ "HeaderCountries": "Land",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Spor",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studioer",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Foreldresensur",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
new file mode 100644
index 000000000..2818fbf6a
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Een ogenblik geduld terwijl uw Emby Server-database wordt bijgewerkt. {0}% voltooid.",
+ "AppDeviceValues": "App: {0}, Apparaat: {1}",
+ "UserDownloadingItemWithValues": "{0} download {1}",
+ "FolderTypeMixed": "Gemengde inhoud",
+ "FolderTypeMovies": "Films",
+ "FolderTypeMusic": "Muziek",
+ "FolderTypeAdultVideos": "Adult video's",
+ "FolderTypePhotos": "Foto's",
+ "FolderTypeMusicVideos": "Muziek video's",
+ "FolderTypeHomeVideos": "Thuis video's",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Boeken",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "overerven",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "Personen",
+ "ValueSpecialEpisodeName": "Speciaal - {0}",
+ "LabelChapterName": "Hoofdstuk {0}",
+ "NameSeasonNumber": "Seizoen {0}",
+ "LabelExit": "Afsluiten",
+ "LabelVisitCommunity": "Bezoek Gemeenschap",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api documentatie",
+ "LabelDeveloperResources": "Ontwikkelaars bronnen",
+ "LabelBrowseLibrary": "Bekijk bibliotheek",
+ "LabelConfigureServer": "Emby Configureren",
+ "LabelRestartServer": "Server herstarten",
+ "CategorySync": "Sync",
+ "CategoryUser": "Gebruiker",
+ "CategorySystem": "Systeem",
+ "CategoryApplication": "Toepassing",
+ "CategoryPlugin": "Plug-in",
+ "NotificationOptionPluginError": "Plug-in fout",
+ "NotificationOptionApplicationUpdateAvailable": "Programma-update beschikbaar",
+ "NotificationOptionApplicationUpdateInstalled": "Programma-update ge\u00efnstalleerd",
+ "NotificationOptionPluginUpdateInstalled": "Plug-in-update ge\u00efnstalleerd",
+ "NotificationOptionPluginInstalled": "Plug-in ge\u00efnstalleerd",
+ "NotificationOptionPluginUninstalled": "Plug-in verwijderd",
+ "NotificationOptionVideoPlayback": "Video afspelen gestart",
+ "NotificationOptionAudioPlayback": "Geluid afspelen gestart",
+ "NotificationOptionGamePlayback": "Game gestart",
+ "NotificationOptionVideoPlaybackStopped": "Video afspelen gestopt",
+ "NotificationOptionAudioPlaybackStopped": "Geluid afspelen gestopt",
+ "NotificationOptionGamePlaybackStopped": "Afspelen spel gestopt",
+ "NotificationOptionTaskFailed": "Mislukken van de geplande taak",
+ "NotificationOptionInstallationFailed": "Mislukken van de installatie",
+ "NotificationOptionNewLibraryContent": "Nieuwe content toegevoegd",
+ "NotificationOptionNewLibraryContentMultiple": "Nieuwe content toegevoegd (meerdere)",
+ "NotificationOptionCameraImageUploaded": "Camera afbeelding ge\u00fcpload",
+ "NotificationOptionUserLockedOut": "Gebruikersaccount vergrendeld",
+ "NotificationOptionServerRestartRequired": "Server herstart nodig",
+ "ViewTypePlaylists": "Afspeellijsten",
+ "ViewTypeMovies": "Films",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Muziek",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artiesten",
+ "ViewTypeBoxSets": "Collecties",
+ "ViewTypeChannels": "Kanalen",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Nu uitgezonden",
+ "ViewTypeLatestGames": "Nieuwste games",
+ "ViewTypeRecentlyPlayedGames": "Recent gespeelt",
+ "ViewTypeGameFavorites": "Favorieten",
+ "ViewTypeGameSystems": "Game systemen",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Hervatten",
+ "ViewTypeTvNextUp": "Volgende",
+ "ViewTypeTvLatest": "Nieuwste",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favoriete Series",
+ "ViewTypeTvFavoriteEpisodes": "Favoriete Afleveringen",
+ "ViewTypeMovieResume": "Hervatten",
+ "ViewTypeMovieLatest": "Nieuwste",
+ "ViewTypeMovieMovies": "Films",
+ "ViewTypeMovieCollections": "Collecties",
+ "ViewTypeMovieFavorites": "Favorieten",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Nieuwste",
+ "ViewTypeMusicPlaylists": "Afspeellijsten",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album artiesten",
+ "HeaderOtherDisplaySettings": "Beeld instellingen",
+ "ViewTypeMusicSongs": "Nummers",
+ "ViewTypeMusicFavorites": "Favorieten",
+ "ViewTypeMusicFavoriteAlbums": "Favoriete albums",
+ "ViewTypeMusicFavoriteArtists": "Favoriete artiesten",
+ "ViewTypeMusicFavoriteSongs": "Favoriete nummers",
+ "ViewTypeFolders": "Mappen",
+ "ViewTypeLiveTvRecordingGroups": "Opnamen",
+ "ViewTypeLiveTvChannels": "Kanalen",
+ "ScheduledTaskFailedWithName": "{0} is mislukt",
+ "LabelRunningTimeValue": "Looptijd: {0}",
+ "ScheduledTaskStartedWithName": "{0} is gestart",
+ "VersionNumber": "Versie {0}",
+ "PluginInstalledWithName": "{0} is ge\u00efnstalleerd",
+ "PluginUpdatedWithName": "{0} is bijgewerkt",
+ "PluginUninstalledWithName": "{0} is gede\u00efnstalleerd",
+ "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek",
+ "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek",
+ "LabelIpAddressValue": "IP adres: {0}",
+ "DeviceOnlineWithName": "{0} is verbonden",
+ "UserOnlineFromDevice": "{0} heeft verbinding met {1}",
+ "ProviderValue": "Aanbieder: {0}",
+ "SubtitlesDownloadedForItem": "Ondertiteling voor {0} is gedownload",
+ "UserConfigurationUpdatedWithName": "Gebruikersinstellingen voor {0} zijn bijgewerkt",
+ "UserCreatedWithName": "Gebruiker {0} is aangemaakt",
+ "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd",
+ "UserDeletedWithName": "Gebruiker {0} is verwijderd",
+ "MessageServerConfigurationUpdated": "Server configuratie is bijgewerkt",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de server configuratie is bijgewerkt",
+ "MessageApplicationUpdated": "Emby Server is bijgewerkt",
+ "FailedLoginAttemptWithUserName": "Mislukte aanmeld poging van {0}",
+ "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd",
+ "DeviceOfflineWithName": "{0} is losgekoppeld",
+ "UserLockedOutWithName": "Gebruikersaccount {0} is vergrendeld",
+ "UserOfflineFromDevice": "Verbinding van {0} met {1} is verbroken",
+ "UserStartedPlayingItemWithValues": "{0} heeft afspelen van {1} gestart",
+ "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt",
+ "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt",
+ "HeaderUnidentified": "One\u00efdentificaard",
+ "HeaderImagePrimary": "Primair",
+ "HeaderImageBackdrop": "Achtergrond",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Afbeelding gebruiker",
+ "HeaderOverview": "Overzicht",
+ "HeaderShortOverview": "Kort overzicht",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Ernst",
+ "HeaderUser": "Gebruiker",
+ "HeaderName": "Naam",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premi\u00e8re Datum",
+ "HeaderDateAdded": "Datum toegevoegd",
+ "HeaderReleaseDate": "Uitgave datum",
+ "HeaderRuntime": "Speelduur",
+ "HeaderPlayCount": "Afspeel telling",
+ "HeaderSeason": "Seizoen",
+ "HeaderSeasonNumber": "Seizoen nummer",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Zender",
+ "HeaderYear": "Jaar:",
+ "HeaderYears": "Jaren:",
+ "HeaderParentalRating": "Kijkwijzer classificatie",
+ "HeaderCommunityRating": "Gemeenschap cijfer",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game systemen",
+ "HeaderPlayers": "Spelers:",
+ "HeaderAlbumArtists": "Album artiesten",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Schijf",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Geluid",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Ingesloten afbeelding",
+ "HeaderResolution": "Resolutie",
+ "HeaderSubtitles": "Ondertiteling",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Landen",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Muziek artiest",
+ "HeaderLocked": "Vergrendeld",
+ "HeaderStudios": "Studio's",
+ "HeaderActor": "Acteurs",
+ "HeaderComposer": "Componisten",
+ "HeaderDirector": "Regiseurs",
+ "HeaderGuestStar": "Gast ster",
+ "HeaderProducer": "Producenten",
+ "HeaderWriter": "Schrijvers",
+ "HeaderParentalRatings": "Ouderlijke toezicht",
+ "HeaderCommunityRatings": "Gemeenschapswaardering",
+ "StartupEmbyServerIsLoading": "Emby Server is aan het laden, probeer het later opnieuw."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
new file mode 100644
index 000000000..cdaa87c4d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Prosz\u0119 czeka\u0107 na koniec aktualizacji biblioteki. Post\u0119p: {0}%",
+ "AppDeviceValues": "Aplikacja: {0}, Urz\u0105dzenie: {1}",
+ "UserDownloadingItemWithValues": "{0} pobiera {1}",
+ "FolderTypeMixed": "Zawarto\u015b\u0107 mieszana",
+ "FolderTypeMovies": "Filmy",
+ "FolderTypeMusic": "Muzyka",
+ "FolderTypeAdultVideos": "Filmy dla doros\u0142ych",
+ "FolderTypePhotos": "Zdj\u0119cia",
+ "FolderTypeMusicVideos": "Teledyski",
+ "FolderTypeHomeVideos": "Filmy domowe",
+ "FolderTypeGames": "Gry",
+ "FolderTypeBooks": "Ksi\u0105\u017cki",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Dziedzicz",
+ "HeaderCastCrew": "Obsada & Eikpa",
+ "HeaderPeople": "Ludzie",
+ "ValueSpecialEpisodeName": "Specjalny - {0}",
+ "LabelChapterName": "Rozdzia\u0142 {0}",
+ "NameSeasonNumber": "Sezon {0}",
+ "LabelExit": "Wyj\u015bcie",
+ "LabelVisitCommunity": "Odwied\u017a spo\u0142eczno\u015b\u0107",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Dokumantacja API",
+ "LabelDeveloperResources": "Materia\u0142y dla deweloper\u00f3w",
+ "LabelBrowseLibrary": "Przegl\u0105daj bibliotek\u0119",
+ "LabelConfigureServer": "Konfiguracja Emby",
+ "LabelRestartServer": "Restart serwera",
+ "CategorySync": "Sync",
+ "CategoryUser": "U\u017cytkownik",
+ "CategorySystem": "System",
+ "CategoryApplication": "Aplikacja",
+ "CategoryPlugin": "Wtyczka",
+ "NotificationOptionPluginError": "Niepowodzenie wtyczki",
+ "NotificationOptionApplicationUpdateAvailable": "Dost\u0119pna aktualizacja aplikacji",
+ "NotificationOptionApplicationUpdateInstalled": "Zainstalowano aktualizacj\u0119 aplikacji",
+ "NotificationOptionPluginUpdateInstalled": "Zainstalowano aktualizacj\u0119 wtyczki",
+ "NotificationOptionPluginInstalled": "Zainstalowano wtyczk\u0119",
+ "NotificationOptionPluginUninstalled": "Odinstalowano wtyczk\u0119",
+ "NotificationOptionVideoPlayback": "Rozpocz\u0119to odtwarzanie wideo",
+ "NotificationOptionAudioPlayback": "Rozpocz\u0119to odtwarzanie audio",
+ "NotificationOptionGamePlayback": "Odtwarzanie gry rozpocz\u0119te",
+ "NotificationOptionVideoPlaybackStopped": "Odtwarzanie wideo zatrzymane",
+ "NotificationOptionAudioPlaybackStopped": "Odtwarzane audio zatrzymane",
+ "NotificationOptionGamePlaybackStopped": "Odtwarzanie gry zatrzymane",
+ "NotificationOptionTaskFailed": "Niepowodzenie zaplanowanego zadania",
+ "NotificationOptionInstallationFailed": "Niepowodzenie instalacji",
+ "NotificationOptionNewLibraryContent": "Nowa zawarto\u015b\u0107 dodana",
+ "NotificationOptionNewLibraryContentMultiple": "Nowa zawarto\u015b\u0107 dodana (wiele)",
+ "NotificationOptionCameraImageUploaded": "Obraz z Kamery dodany",
+ "NotificationOptionUserLockedOut": "U\u017cytkownik zablokowany",
+ "NotificationOptionServerRestartRequired": "Restart serwera wymagany",
+ "ViewTypePlaylists": "Playlisty",
+ "ViewTypeMovies": "Filmy",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Gry",
+ "ViewTypeMusic": "Muzyka",
+ "ViewTypeMusicGenres": "Gatunki",
+ "ViewTypeMusicArtists": "Arty\u015bci",
+ "ViewTypeBoxSets": "Kolekcje",
+ "ViewTypeChannels": "Kana\u0142y",
+ "ViewTypeLiveTV": "TV Na \u017bywo",
+ "ViewTypeLiveTvNowPlaying": "Teraz Transmitowane",
+ "ViewTypeLatestGames": "Ostatnie Gry",
+ "ViewTypeRecentlyPlayedGames": "Ostatnio Odtwarzane",
+ "ViewTypeGameFavorites": "Ulubione",
+ "ViewTypeGameSystems": "Systemy Gier Wideo",
+ "ViewTypeGameGenres": "Gatunki",
+ "ViewTypeTvResume": "Wzn\u00f3w",
+ "ViewTypeTvNextUp": "Nast\u0119pny",
+ "ViewTypeTvLatest": "Najnowsze",
+ "ViewTypeTvShowSeries": "Seriale",
+ "ViewTypeTvGenres": "Gatunki",
+ "ViewTypeTvFavoriteSeries": "Ulubione Seriale",
+ "ViewTypeTvFavoriteEpisodes": "Ulubione Odcinki",
+ "ViewTypeMovieResume": "Wzn\u00f3w",
+ "ViewTypeMovieLatest": "Najnowsze",
+ "ViewTypeMovieMovies": "Filmy",
+ "ViewTypeMovieCollections": "Kolekcje",
+ "ViewTypeMovieFavorites": "Ulubione",
+ "ViewTypeMovieGenres": "Gatunki",
+ "ViewTypeMusicLatest": "Najnowsze",
+ "ViewTypeMusicPlaylists": "Playlisty",
+ "ViewTypeMusicAlbums": "Albumy",
+ "ViewTypeMusicAlbumArtists": "Arty\u015bci albumu",
+ "HeaderOtherDisplaySettings": "Ustawienia Wy\u015bwietlania",
+ "ViewTypeMusicSongs": "Utwory",
+ "ViewTypeMusicFavorites": "Ulubione",
+ "ViewTypeMusicFavoriteAlbums": "Ulubione Albumy",
+ "ViewTypeMusicFavoriteArtists": "Ulubieni Arty\u015bci",
+ "ViewTypeMusicFavoriteSongs": "Ulubione Utwory",
+ "ViewTypeFolders": "Foldery",
+ "ViewTypeLiveTvRecordingGroups": "Nagrania",
+ "ViewTypeLiveTvChannels": "Kana\u0142y",
+ "ScheduledTaskFailedWithName": "{0} niepowodze\u0144",
+ "LabelRunningTimeValue": "Czas trwania: {0}",
+ "ScheduledTaskStartedWithName": "{0} rozpocz\u0119te",
+ "VersionNumber": "Wersja {0}",
+ "PluginInstalledWithName": "{0} zainstalowanych",
+ "PluginUpdatedWithName": "{0} zaktualizowanych",
+ "PluginUninstalledWithName": "{0} odinstalowanych",
+ "ItemAddedWithName": "{0} dodanych do biblioteki",
+ "ItemRemovedWithName": "{0} usuni\u0119tych z biblioteki",
+ "LabelIpAddressValue": "Adres IP: {0}",
+ "DeviceOnlineWithName": "{0} po\u0142\u0105czonych",
+ "UserOnlineFromDevice": "{0} jest online od {1}",
+ "ProviderValue": "Dostawca: {0}",
+ "SubtitlesDownloadedForItem": "Napisy pobrane dla {0}",
+ "UserConfigurationUpdatedWithName": "Konfiguracja u\u017cytkownika zosta\u0142a zaktualizowana dla {0}",
+ "UserCreatedWithName": "U\u017cytkownik {0} zosta\u0142 utworzony",
+ "UserPasswordChangedWithName": "Has\u0142o zosta\u0142o zmienione dla u\u017cytkownika {0}",
+ "UserDeletedWithName": "u\u017cytkownik {0} zosta\u0142 usuni\u0119ty",
+ "MessageServerConfigurationUpdated": "Konfiguracja serwera zosta\u0142a zaktualizowana",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Sekcja {0} konfiguracji serwera zosta\u0142a zaktualizowana",
+ "MessageApplicationUpdated": "Serwer Emby zosta\u0142 zaktualizowany",
+ "FailedLoginAttemptWithUserName": "Nieudana pr\u00f3ba logowania z {0}",
+ "AuthenticationSucceededWithUserName": "{0} zaktualizowanych z powodzeniem",
+ "DeviceOfflineWithName": "{0} zosta\u0142o od\u0142aczonych",
+ "UserLockedOutWithName": "U\u017cytkownik {0} zosta\u0142 zablokowany",
+ "UserOfflineFromDevice": "{0} zosta\u0142o od\u0142\u0105czonych od {1}",
+ "UserStartedPlayingItemWithValues": "{0} rozpocz\u0105\u0142 odtwarzanie {1}",
+ "UserStoppedPlayingItemWithValues": "{0} zatrzyma\u0142 odtwarzanie {1}",
+ "SubtitleDownloadFailureForItem": "Napisy niepobrane dla {0}",
+ "HeaderUnidentified": "Niezidentyfikowane",
+ "HeaderImagePrimary": "Priorytetowy",
+ "HeaderImageBackdrop": "Obraz t\u0142a",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Avatar u\u017cytkownika",
+ "HeaderOverview": "Opis",
+ "HeaderShortOverview": "Kr\u00f3tki Opis",
+ "HeaderType": "Typ",
+ "HeaderSeverity": "Rygor",
+ "HeaderUser": "U\u017cytkownik",
+ "HeaderName": "Nazwa",
+ "HeaderDate": "Data",
+ "HeaderPremiereDate": "Data premiery",
+ "HeaderDateAdded": "Data dodania",
+ "HeaderReleaseDate": "Data wydania",
+ "HeaderRuntime": "D\u0142ugo\u015b\u0107 filmu",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Sezon",
+ "HeaderSeasonNumber": "Numer sezonu",
+ "HeaderSeries": "Seriale:",
+ "HeaderNetwork": "Sie\u0107",
+ "HeaderYear": "Rok:",
+ "HeaderYears": "Lata:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Ocena spo\u0142eczno\u015bci",
+ "HeaderTrailers": "Zwiastuny",
+ "HeaderSpecials": "Specjalne",
+ "HeaderGameSystems": "Systemy gier",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albumy",
+ "HeaderDisc": "P\u0142yta",
+ "HeaderTrack": "\u015acie\u017cka",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Wideo",
+ "HeaderEmbeddedImage": "Osadzony obraz",
+ "HeaderResolution": "Rozdzielczo\u015b\u0107",
+ "HeaderSubtitles": "Napisy",
+ "HeaderGenres": "Gatunki",
+ "HeaderCountries": "Kraje",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Utwory",
+ "HeaderMusicArtist": "Wykonawcy muzyczni",
+ "HeaderLocked": "Zablokowane",
+ "HeaderStudios": "Studia",
+ "HeaderActor": "Aktorzy",
+ "HeaderComposer": "Kopozytorzy",
+ "HeaderDirector": "Re\u017cyszerzy",
+ "HeaderGuestStar": "Go\u015b\u0107 specjalny",
+ "HeaderProducer": "Producenci",
+ "HeaderWriter": "Scenarzy\u015bci",
+ "HeaderParentalRatings": "Ocena rodzicielska",
+ "HeaderCommunityRatings": "Ocena spo\u0142eczno\u015bci",
+ "StartupEmbyServerIsLoading": "Serwer Emby si\u0119 \u0142aduje. Prosz\u0119 spr\u00f3bowa\u0107 za chwil\u0119."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
new file mode 100644
index 000000000..67f204b2e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Por favor, aguarde enquanto a base de dados do Servidor Emby \u00e9 atualizada. {0}% completo.",
+ "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+ "UserDownloadingItemWithValues": "{0} est\u00e1 fazendo download de {1}",
+ "FolderTypeMixed": "Conte\u00fado misto",
+ "FolderTypeMovies": "Filmes",
+ "FolderTypeMusic": "M\u00fasica",
+ "FolderTypeAdultVideos": "V\u00eddeos adultos",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "V\u00eddeos musicais",
+ "FolderTypeHomeVideos": "V\u00eddeos caseiros",
+ "FolderTypeGames": "Jogos",
+ "FolderTypeBooks": "Livros",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Herdar",
+ "HeaderCastCrew": "Elenco & Equipe",
+ "HeaderPeople": "Pessoas",
+ "ValueSpecialEpisodeName": "Especial - {0}",
+ "LabelChapterName": "Cap\u00edtulo {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "LabelExit": "Sair",
+ "LabelVisitCommunity": "Visitar a Comunidade",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documenta\u00e7\u00e3o da Api",
+ "LabelDeveloperResources": "Recursos do Desenvolvedor",
+ "LabelBrowseLibrary": "Explorar Biblioteca",
+ "LabelConfigureServer": "Configurar o Emby",
+ "LabelRestartServer": "Reiniciar Servidor",
+ "CategorySync": "Sincroniza\u00e7\u00e3o",
+ "CategoryUser": "Usu\u00e1rio",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Aplica\u00e7\u00e3o",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Falha no plugin",
+ "NotificationOptionApplicationUpdateAvailable": "Atualiza\u00e7\u00e3o da aplica\u00e7\u00e3o disponivel",
+ "NotificationOptionApplicationUpdateInstalled": "Atualiza\u00e7\u00e3o da aplica\u00e7\u00e3o instalada",
+ "NotificationOptionPluginUpdateInstalled": "Atualiza\u00e7\u00e3o do plugin instalada",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "NotificationOptionVideoPlayback": "Reprodu\u00e7\u00e3o de v\u00eddeo iniciada",
+ "NotificationOptionAudioPlayback": "Reprodu\u00e7\u00e3o de \u00e1udio iniciada",
+ "NotificationOptionGamePlayback": "Reprodu\u00e7\u00e3o de jogo iniciada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodu\u00e7\u00e3o de v\u00eddeo parada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodu\u00e7\u00e3o de \u00e1udio parada",
+ "NotificationOptionGamePlaybackStopped": "Reprodu\u00e7\u00e3o de jogo parada",
+ "NotificationOptionTaskFailed": "Falha na tarefa agendada",
+ "NotificationOptionInstallationFailed": "Falha na instala\u00e7\u00e3o",
+ "NotificationOptionNewLibraryContent": "Novo conte\u00fado adicionado",
+ "NotificationOptionNewLibraryContentMultiple": "Novo conte\u00fado adicionado (m\u00faltiplo)",
+ "NotificationOptionCameraImageUploaded": "Imagem da c\u00e2mera carregada",
+ "NotificationOptionUserLockedOut": "Usu\u00e1rio bloqueado",
+ "NotificationOptionServerRestartRequired": "Necessidade de reiniciar servidor",
+ "ViewTypePlaylists": "Listas de Reprodu\u00e7\u00e3o",
+ "ViewTypeMovies": "Filmes",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Jogos",
+ "ViewTypeMusic": "M\u00fasicas",
+ "ViewTypeMusicGenres": "G\u00eaneros",
+ "ViewTypeMusicArtists": "Artistas",
+ "ViewTypeBoxSets": "Cole\u00e7\u00f5es",
+ "ViewTypeChannels": "Canais",
+ "ViewTypeLiveTV": "TV ao Vivo",
+ "ViewTypeLiveTvNowPlaying": "Exibindo Agora",
+ "ViewTypeLatestGames": "Jogos Recentes",
+ "ViewTypeRecentlyPlayedGames": "Reproduzido Recentemente",
+ "ViewTypeGameFavorites": "Favoritos",
+ "ViewTypeGameSystems": "Sistemas de Jogo",
+ "ViewTypeGameGenres": "G\u00eaneros",
+ "ViewTypeTvResume": "Retomar",
+ "ViewTypeTvNextUp": "Pr\u00f3ximos",
+ "ViewTypeTvLatest": "Recentes",
+ "ViewTypeTvShowSeries": "S\u00e9ries",
+ "ViewTypeTvGenres": "G\u00eaneros",
+ "ViewTypeTvFavoriteSeries": "S\u00e9ries Favoritas",
+ "ViewTypeTvFavoriteEpisodes": "Epis\u00f3dios Favoritos",
+ "ViewTypeMovieResume": "Retomar",
+ "ViewTypeMovieLatest": "Recentes",
+ "ViewTypeMovieMovies": "Filmes",
+ "ViewTypeMovieCollections": "Cole\u00e7\u00f5es",
+ "ViewTypeMovieFavorites": "Favoritos",
+ "ViewTypeMovieGenres": "G\u00eaneros",
+ "ViewTypeMusicLatest": "Recentes",
+ "ViewTypeMusicPlaylists": "Listas de Reprodu\u00e7\u00e3o",
+ "ViewTypeMusicAlbums": "\u00c1lbuns",
+ "ViewTypeMusicAlbumArtists": "Artistas do \u00c1lbum",
+ "HeaderOtherDisplaySettings": "Ajustes de Exibi\u00e7\u00e3o",
+ "ViewTypeMusicSongs": "M\u00fasicas",
+ "ViewTypeMusicFavorites": "Favoritos",
+ "ViewTypeMusicFavoriteAlbums": "\u00c1lbuns Favoritos",
+ "ViewTypeMusicFavoriteArtists": "Artistas Favoritos",
+ "ViewTypeMusicFavoriteSongs": "M\u00fasicas Favoritas",
+ "ViewTypeFolders": "Pastas",
+ "ViewTypeLiveTvRecordingGroups": "Grava\u00e7\u00f5es",
+ "ViewTypeLiveTvChannels": "Canais",
+ "ScheduledTaskFailedWithName": "{0} falhou",
+ "LabelRunningTimeValue": "Dura\u00e7\u00e3o: {0}",
+ "ScheduledTaskStartedWithName": "{0} iniciado",
+ "VersionNumber": "Vers\u00e3o {0}",
+ "PluginInstalledWithName": "{0} foi instalado",
+ "PluginUpdatedWithName": "{0} foi atualizado",
+ "PluginUninstalledWithName": "{0} foi desinstalado",
+ "ItemAddedWithName": "{0} foi adicionado \u00e0 biblioteca",
+ "ItemRemovedWithName": "{0} foi removido da biblioteca",
+ "LabelIpAddressValue": "Endere\u00e7o Ip: {0}",
+ "DeviceOnlineWithName": "{0} est\u00e1 conectado",
+ "UserOnlineFromDevice": "{0} est\u00e1 ativo em {1}",
+ "ProviderValue": "Provedor: {0}",
+ "SubtitlesDownloadedForItem": "Legendas baixadas para {0}",
+ "UserConfigurationUpdatedWithName": "A configura\u00e7\u00e3o do usu\u00e1rio {0} foi atualizada",
+ "UserCreatedWithName": "O usu\u00e1rio {0} foi criado",
+ "UserPasswordChangedWithName": "A senha do usu\u00e1rio {0} foi alterada",
+ "UserDeletedWithName": "O usu\u00e1rio {0} foi exclu\u00eddo",
+ "MessageServerConfigurationUpdated": "A configura\u00e7\u00e3o do servidor foi atualizada",
+ "MessageNamedServerConfigurationUpdatedWithValue": "A se\u00e7\u00e3o {0} da configura\u00e7\u00e3o do servidor foi atualizada",
+ "MessageApplicationUpdated": "O Servidor Emby foi atualizado",
+ "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}",
+ "AuthenticationSucceededWithUserName": "{0} autenticou-se com sucesso",
+ "DeviceOfflineWithName": "{0} foi desconectado",
+ "UserLockedOutWithName": "Usu\u00e1rio {0} foi bloqueado",
+ "UserOfflineFromDevice": "{0} foi desconectado de {1}",
+ "UserStartedPlayingItemWithValues": "{0} come\u00e7ou a reproduzir {1}",
+ "UserStoppedPlayingItemWithValues": "{0} parou de reproduzir {1}",
+ "SubtitleDownloadFailureForItem": "Falha ao baixar legendas para {0}",
+ "HeaderUnidentified": "N\u00e3o-identificado",
+ "HeaderImagePrimary": "Principal",
+ "HeaderImageBackdrop": "Imagem de Fundo",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "Imagem do Usu\u00e1rio",
+ "HeaderOverview": "Sinopse",
+ "HeaderShortOverview": "Sinopse curta",
+ "HeaderType": "Tipo",
+ "HeaderSeverity": "Severidade",
+ "HeaderUser": "Usu\u00e1rio",
+ "HeaderName": "Nome",
+ "HeaderDate": "Data",
+ "HeaderPremiereDate": "Data da Estr\u00e9ia",
+ "HeaderDateAdded": "Data da Adi\u00e7\u00e3o",
+ "HeaderReleaseDate": "Data de lan\u00e7amento",
+ "HeaderRuntime": "Dura\u00e7\u00e3o",
+ "HeaderPlayCount": "N\u00famero de Reprodu\u00e7\u00f5es",
+ "HeaderSeason": "Temporada",
+ "HeaderSeasonNumber": "N\u00famero da temporada",
+ "HeaderSeries": "S\u00e9rie:",
+ "HeaderNetwork": "Rede de TV",
+ "HeaderYear": "Ano:",
+ "HeaderYears": "Anos:",
+ "HeaderParentalRating": "Classifica\u00e7\u00e3o Parental",
+ "HeaderCommunityRating": "Avalia\u00e7\u00e3o da Comunidade",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Especiais",
+ "HeaderGameSystems": "Sistemas de Jogo",
+ "HeaderPlayers": "Jogadores:",
+ "HeaderAlbumArtists": "Artistas do \u00c1lbum",
+ "HeaderAlbums": "\u00c1lbuns",
+ "HeaderDisc": "Disco",
+ "HeaderTrack": "Faixa",
+ "HeaderAudio": "\u00c1udio",
+ "HeaderVideo": "V\u00eddeo",
+ "HeaderEmbeddedImage": "Imagem incorporada",
+ "HeaderResolution": "Resolu\u00e7\u00e3o",
+ "HeaderSubtitles": "Legendas",
+ "HeaderGenres": "G\u00eaneros",
+ "HeaderCountries": "Pa\u00edses",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Faixas",
+ "HeaderMusicArtist": "Artista da m\u00fasica",
+ "HeaderLocked": "Travado",
+ "HeaderStudios": "Est\u00fadios",
+ "HeaderActor": "Atores",
+ "HeaderComposer": "Compositores",
+ "HeaderDirector": "Diretores",
+ "HeaderGuestStar": "Ator convidado",
+ "HeaderProducer": "Produtores",
+ "HeaderWriter": "Escritores",
+ "HeaderParentalRatings": "Classifica\u00e7\u00f5es Parentais",
+ "HeaderCommunityRatings": "Avalia\u00e7\u00f5es da comunidade",
+ "StartupEmbyServerIsLoading": "O Servidor Emby est\u00e1 carregando. Por favor, tente novamente em breve."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
new file mode 100644
index 000000000..f12939b10
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Filmes",
+ "FolderTypeMusic": "M\u00fasica",
+ "FolderTypeAdultVideos": "V\u00eddeos adultos",
+ "FolderTypePhotos": "Fotos",
+ "FolderTypeMusicVideos": "V\u00eddeos musicais",
+ "FolderTypeHomeVideos": "V\u00eddeos caseiros",
+ "FolderTypeGames": "Jogos",
+ "FolderTypeBooks": "Livros",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Elenco e Equipa",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Sair",
+ "LabelVisitCommunity": "Visitar a Comunidade",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documenta\u00e7\u00e3o da API",
+ "LabelDeveloperResources": "Recursos do Programador",
+ "LabelBrowseLibrary": "Navegar pela Biblioteca",
+ "LabelConfigureServer": "Configurar o Emby",
+ "LabelRestartServer": "Reiniciar Servidor",
+ "CategorySync": "Sincroniza\u00e7\u00e3o",
+ "CategoryUser": "Utilizador",
+ "CategorySystem": "Sistema",
+ "CategoryApplication": "Aplica\u00e7\u00e3o",
+ "CategoryPlugin": "Extens\u00e3o",
+ "NotificationOptionPluginError": "Falha na extens\u00e3o",
+ "NotificationOptionApplicationUpdateAvailable": "Dispon\u00edvel atualiza\u00e7\u00e3o da aplica\u00e7\u00e3o",
+ "NotificationOptionApplicationUpdateInstalled": "Instalada atualiza\u00e7\u00e3o da aplica\u00e7\u00e3o",
+ "NotificationOptionPluginUpdateInstalled": "Instalada atualiza\u00e7\u00e3o da extens\u00e3o",
+ "NotificationOptionPluginInstalled": "Extens\u00e3o instalada",
+ "NotificationOptionPluginUninstalled": "Extens\u00e3o desinstalada",
+ "NotificationOptionVideoPlayback": "Reprodu\u00e7\u00e3o de v\u00eddeo iniciada",
+ "NotificationOptionAudioPlayback": "Reprodu\u00e7\u00e3o de \u00e1udio iniciada",
+ "NotificationOptionGamePlayback": "Reprodu\u00e7\u00e3o de jogo iniciada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodu\u00e7\u00e3o de v\u00eddeo parada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodu\u00e7\u00e3o de \u00e1udio parada",
+ "NotificationOptionGamePlaybackStopped": "Reprodu\u00e7\u00e3o de jogo parada",
+ "NotificationOptionTaskFailed": "Falha na tarefa agendada",
+ "NotificationOptionInstallationFailed": "Falha na instala\u00e7\u00e3o",
+ "NotificationOptionNewLibraryContent": "Adicionado novo conte\u00fado",
+ "NotificationOptionNewLibraryContentMultiple": "Novo conte\u00fado adicionado (m\u00faltiplo)",
+ "NotificationOptionCameraImageUploaded": "Imagem da c\u00e2mara carregada",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "\u00c9 necess\u00e1rio reiniciar o servidor",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "TV ao Vivo",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Reproduzido Recentemente",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "\u00daltimas",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "\u00daltimas",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "\u00daltimas",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Vers\u00e3o {0}",
+ "PluginInstalledWithName": "{0} foi instalado",
+ "PluginUpdatedWithName": "{0} foi atualizado",
+ "PluginUninstalledWithName": "{0} foi desinstalado",
+ "ItemAddedWithName": "{0} foi adicionado \u00e0 biblioteca",
+ "ItemRemovedWithName": "{0} foi removido da biblioteca",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} est\u00e1 conectado",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Nome",
+ "HeaderDate": "Data",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "\u00c1udio",
+ "HeaderVideo": "V\u00eddeo",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Estado",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
new file mode 100644
index 000000000..c58df27d5
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Continut mixt",
+ "FolderTypeMovies": "Filme",
+ "FolderTypeMusic": "Muzica",
+ "FolderTypeAdultVideos": "Filme Porno",
+ "FolderTypePhotos": "Fotografii",
+ "FolderTypeMusicVideos": "Videoclipuri",
+ "FolderTypeHomeVideos": "Video Personale",
+ "FolderTypeGames": "Jocuri",
+ "FolderTypeBooks": "Carti",
+ "FolderTypeTvShows": "Seriale TV",
+ "FolderTypeInherit": "Relationat",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Iesire",
+ "LabelVisitCommunity": "Viziteaza comunitatea",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Documentatie Api",
+ "LabelDeveloperResources": "Resurse Dezvoltator",
+ "LabelBrowseLibrary": "Rasfoieste Librarie",
+ "LabelConfigureServer": "Configureaza Emby",
+ "LabelRestartServer": "Restarteaza Server",
+ "CategorySync": "Sincronizeaza",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Muzica",
+ "HeaderVideo": "Filme",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
new file mode 100644
index 000000000..62fe3b496
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0431\u0430\u0437\u0430 \u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0430\u0448\u0435\u043c Emby Server \u043c\u043e\u0434\u0435\u0440\u043d\u0438\u0437\u0438\u0440\u0443\u0435\u0442\u0441\u044f. {0} % \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e.",
+ "AppDeviceValues": "\u041f\u0440\u0438\u043b.: {0}, \u0423\u0441\u0442\u0440.: {1}",
+ "UserDownloadingItemWithValues": "{0} \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442 {1}",
+ "FolderTypeMixed": "\u0421\u043c\u0435\u0448\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435",
+ "FolderTypeMovies": "\u041a\u0438\u043d\u043e",
+ "FolderTypeMusic": "\u041c\u0443\u0437\u044b\u043a\u0430",
+ "FolderTypeAdultVideos": "\u0414\u043b\u044f \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0445",
+ "FolderTypePhotos": "\u0424\u043e\u0442\u043e",
+ "FolderTypeMusicVideos": "\u041c\u0443\u0437. \u0432\u0438\u0434\u0435\u043e",
+ "FolderTypeHomeVideos": "\u0414\u043e\u043c. \u0432\u0438\u0434\u0435\u043e",
+ "FolderTypeGames": "\u0418\u0433\u0440\u044b",
+ "FolderTypeBooks": "\u041b\u0438\u0442\u0435\u0440\u0430\u0442\u0443\u0440\u0430",
+ "FolderTypeTvShows": "\u0422\u0412",
+ "FolderTypeInherit": "\u041d\u0430\u0441\u043b\u0435\u0434\u0443\u0435\u043c\u044b\u0439",
+ "HeaderCastCrew": "\u0421\u043d\u0438\u043c\u0430\u043b\u0438\u0441\u044c \u0438 \u0441\u043d\u0438\u043c\u0430\u043b\u0438",
+ "HeaderPeople": "\u041b\u044e\u0434\u0438",
+ "ValueSpecialEpisodeName": "\u0421\u043f\u0435\u0446\u044d\u043f\u0438\u0437\u043e\u0434 - {0}",
+ "LabelChapterName": "\u0421\u0446\u0435\u043d\u0430 {0}",
+ "NameSeasonNumber": "\u0421\u0435\u0437\u043e\u043d {0}",
+ "LabelExit": "\u0412\u044b\u0445\u043e\u0434",
+ "LabelVisitCommunity": "\u041f\u043e\u0441\u0435\u0449\u0435\u043d\u0438\u0435 \u0421\u043e\u043e\u0431\u0449\u0435\u0441\u0442\u0432\u0430",
+ "LabelGithub": "GitHub",
+ "LabelApiDocumentation": "\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f \u043f\u043e API",
+ "LabelDeveloperResources": "\u0420\u0435\u0441\u0443\u0440\u0441\u044b \u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432",
+ "LabelBrowseLibrary": "\u041d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044f \u043f\u043e \u043c\u0435\u0434\u0438\u0430\u0442\u0435\u043a\u0435",
+ "LabelConfigureServer": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Emby",
+ "LabelRestartServer": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430",
+ "CategorySync": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f",
+ "CategoryUser": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
+ "CategorySystem": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430",
+ "CategoryApplication": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
+ "CategoryPlugin": "\u041f\u043b\u0430\u0433\u0438\u043d",
+ "NotificationOptionPluginError": "\u0421\u0431\u043e\u0439 \u043f\u043b\u0430\u0433\u0438\u043d\u0430",
+ "NotificationOptionApplicationUpdateAvailable": "\u0418\u043c\u0435\u0435\u0442\u0441\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f",
+ "NotificationOptionApplicationUpdateInstalled": "\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e",
+ "NotificationOptionPluginUpdateInstalled": "\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043b\u0430\u0433\u0438\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e",
+ "NotificationOptionPluginInstalled": "\u041f\u043b\u0430\u0433\u0438\u043d \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d",
+ "NotificationOptionPluginUninstalled": "\u041f\u043b\u0430\u0433\u0438\u043d \u0443\u0434\u0430\u043b\u0451\u043d",
+ "NotificationOptionVideoPlayback": "\u0412\u043e\u0441\u043f\u0440-\u0438\u0435 \u0432\u0438\u0434\u0435\u043e \u0437\u0430\u043f-\u043d\u043e",
+ "NotificationOptionAudioPlayback": "\u0412\u043e\u0441\u043f\u0440-\u0438\u0435 \u0430\u0443\u0434\u0438\u043e \u0437\u0430\u043f-\u043d\u043e",
+ "NotificationOptionGamePlayback": "\u0412\u043e\u0441\u043f\u0440-\u0438\u0435 \u0438\u0433\u0440\u044b \u0437\u0430\u043f-\u043d\u043e",
+ "NotificationOptionVideoPlaybackStopped": "\u0412\u043e\u0441\u043f-\u0438\u0435 \u0432\u0438\u0434\u0435\u043e \u043e\u0441\u0442-\u043d\u043e",
+ "NotificationOptionAudioPlaybackStopped": "\u0412\u043e\u0441\u043f-\u0438\u0435 \u0430\u0443\u0434\u0438\u043e \u043e\u0441\u0442-\u043d\u043e",
+ "NotificationOptionGamePlaybackStopped": "\u0412\u043e\u0441\u043f-\u0438\u0435 \u0438\u0433\u0440\u044b \u043e\u0441\u0442-\u043d\u043e",
+ "NotificationOptionTaskFailed": "\u0421\u0431\u043e\u0439 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u043d\u043e\u0439 \u0437\u0430\u0434\u0430\u0447\u0438",
+ "NotificationOptionInstallationFailed": "\u0421\u0431\u043e\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438",
+ "NotificationOptionNewLibraryContent": "\u041d\u043e\u0432\u043e\u0435 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e",
+ "NotificationOptionNewLibraryContentMultiple": "\u041d\u043e\u0432\u043e\u0435 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e (\u043c\u043d\u043e\u0433\u043e\u043a\u0440\u0430\u0442\u043d\u043e)",
+ "NotificationOptionCameraImageUploaded": "\u041f\u0440\u043e\u0438\u0437\u0432\u0435\u0434\u0435\u043d\u0430 \u0432\u044b\u043a\u043b\u0430\u0434\u043a\u0430 \u043e\u0442\u0441\u043d\u044f\u0442\u043e\u0433\u043e \u0441 \u043a\u0430\u043c\u0435\u0440\u044b",
+ "NotificationOptionUserLockedOut": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d",
+ "NotificationOptionServerRestartRequired": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430",
+ "ViewTypePlaylists": "\u041f\u043b\u0435\u0439-\u043b\u0438\u0441\u0442\u044b",
+ "ViewTypeMovies": "\u041a\u0438\u043d\u043e",
+ "ViewTypeTvShows": "\u0422\u0412",
+ "ViewTypeGames": "\u0418\u0433\u0440\u044b",
+ "ViewTypeMusic": "\u041c\u0443\u0437\u044b\u043a\u0430",
+ "ViewTypeMusicGenres": "\u0416\u0430\u043d\u0440\u044b",
+ "ViewTypeMusicArtists": "\u0418\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u0438",
+ "ViewTypeBoxSets": "\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438",
+ "ViewTypeChannels": "\u041a\u0430\u043d\u0430\u043b\u044b",
+ "ViewTypeLiveTV": "\u042d\u0444\u0438\u0440",
+ "ViewTypeLiveTvNowPlaying": "\u0412 \u044d\u0444\u0438\u0440\u0435",
+ "ViewTypeLatestGames": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0435 \u0438\u0433\u0440\u044b",
+ "ViewTypeRecentlyPlayedGames": "C\u044b\u0433\u0440\u0430\u043d\u043d\u044b\u0435 \u043d\u0435\u0434\u0430\u0432\u043d\u043e",
+ "ViewTypeGameFavorites": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435",
+ "ViewTypeGameSystems": "\u0418\u0433\u0440\u043e\u0432\u044b\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u044b",
+ "ViewTypeGameGenres": "\u0416\u0430\u043d\u0440\u044b",
+ "ViewTypeTvResume": "\u0412\u043e\u0437\u043e\u0431\u043d\u043e\u0432\u0438\u043c\u043e\u0435",
+ "ViewTypeTvNextUp": "\u041e\u0447\u0435\u0440\u0435\u0434\u043d\u043e\u0435",
+ "ViewTypeTvLatest": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0435",
+ "ViewTypeTvShowSeries": "\u0421\u0435\u0440\u0438\u0430\u043b\u044b",
+ "ViewTypeTvGenres": "\u0416\u0430\u043d\u0440\u044b",
+ "ViewTypeTvFavoriteSeries": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u0440\u0438\u0430\u043b\u044b",
+ "ViewTypeTvFavoriteEpisodes": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u044d\u043f\u0438\u0437\u043e\u0434\u044b",
+ "ViewTypeMovieResume": "\u0412\u043e\u0437\u043e\u0431\u043d\u043e\u0432\u0438\u043c\u043e\u0435",
+ "ViewTypeMovieLatest": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0435",
+ "ViewTypeMovieMovies": "\u0424\u0438\u043b\u044c\u043c\u044b",
+ "ViewTypeMovieCollections": "\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438",
+ "ViewTypeMovieFavorites": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435",
+ "ViewTypeMovieGenres": "\u0416\u0430\u043d\u0440\u044b",
+ "ViewTypeMusicLatest": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0435",
+ "ViewTypeMusicPlaylists": "\u041f\u043b\u0435\u0439-\u043b\u0438\u0441\u0442\u044b",
+ "ViewTypeMusicAlbums": "\u0410\u043b\u044c\u0431\u043e\u043c\u044b",
+ "ViewTypeMusicAlbumArtists": "\u0418\u0441\u043f-\u043b\u0438 \u0430\u043b\u044c\u0431\u043e\u043c\u0430",
+ "HeaderOtherDisplaySettings": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f",
+ "ViewTypeMusicSongs": "\u041a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
+ "ViewTypeMusicFavorites": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435",
+ "ViewTypeMusicFavoriteAlbums": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u0430\u043b\u044c\u0431\u043e\u043c\u044b",
+ "ViewTypeMusicFavoriteArtists": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u0438\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u0438",
+ "ViewTypeMusicFavoriteSongs": "\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u044b\u0435 \u043a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
+ "ViewTypeFolders": "\u041f\u0430\u043f\u043a\u0438",
+ "ViewTypeLiveTvRecordingGroups": "\u0417\u0430\u043f\u0438\u0441\u0438",
+ "ViewTypeLiveTvChannels": "\u041a\u0430\u043d\u0430\u043b\u044b",
+ "ScheduledTaskFailedWithName": "{0} - \u043d\u0435\u0443\u0434\u0430\u0447\u043d\u0430",
+ "LabelRunningTimeValue": "\u0412\u0440\u0435\u043c\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f: {0}",
+ "ScheduledTaskStartedWithName": "{0} - \u0437\u0430\u043f\u0443\u0449\u0435\u043d\u0430",
+ "VersionNumber": "\u0412\u0435\u0440\u0441\u0438\u044f {0}",
+ "PluginInstalledWithName": "{0} - \u0431\u044b\u043b\u043e \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e",
+ "PluginUpdatedWithName": "{0} - \u0431\u044b\u043b\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e",
+ "PluginUninstalledWithName": "{0} - \u0431\u044b\u043b\u043e \u0443\u0434\u0430\u043b\u0435\u043d\u043e",
+ "ItemAddedWithName": "{0} (\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 \u043c\u0435\u0434\u0438\u0430\u0442\u0435\u043a\u0443)",
+ "ItemRemovedWithName": "{0} (\u0438\u0437\u044a\u044f\u0442\u043e \u0438\u0437 \u043c\u0435\u0434\u0438\u0430\u0442\u0435\u043a\u0438)",
+ "LabelIpAddressValue": "IP-\u0430\u0434\u0440\u0435\u0441: {0}",
+ "DeviceOnlineWithName": "{0} - \u043f\u043e\u0434\u043a\u043b. \u0443\u0441\u0442-\u043d\u043e",
+ "UserOnlineFromDevice": "{0} - \u043f\u043e\u0434\u043a\u043b. \u0441 {1} \u0443\u0441\u0442-\u043d\u043e",
+ "ProviderValue": "\u041f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a: {0}",
+ "SubtitlesDownloadedForItem": "\u0421\u0443\u0431\u0442\u0438\u0442\u0440\u044b \u0434\u043b\u044f {0} \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u043b\u0438\u0441\u044c",
+ "UserConfigurationUpdatedWithName": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u043b\u044c\u0437-\u043b\u044f {0} \u0431\u044b\u043b\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430",
+ "UserCreatedWithName": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c {0} \u0431\u044b\u043b \u0441\u043e\u0437\u0434\u0430\u043d",
+ "UserPasswordChangedWithName": "\u041f\u0430\u0440\u043e\u043b\u044c \u043f\u043e\u043b\u044c\u0437-\u043b\u044f {0} \u0431\u044b\u043b \u0438\u0437\u043c\u0435\u043d\u0451\u043d",
+ "UserDeletedWithName": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c {0} \u0431\u044b\u043b \u0443\u0434\u0430\u043b\u0451\u043d",
+ "MessageServerConfigurationUpdated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u0431\u044b\u043b\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430",
+ "MessageNamedServerConfigurationUpdatedWithValue": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 (\u0440\u0430\u0437\u0434\u0435\u043b {0}) \u0431\u044b\u043b\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430",
+ "MessageApplicationUpdated": "Emby Server \u0431\u044b\u043b \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d",
+ "FailedLoginAttemptWithUserName": "{0} - \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u0432\u0445\u043e\u0434\u0430 \u043d\u0435\u0443\u0434\u0430\u0447\u043d\u0430",
+ "AuthenticationSucceededWithUserName": "{0} - \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0443\u0441\u043f\u0435\u0448\u043d\u0430",
+ "DeviceOfflineWithName": "{0} - \u043f\u043e\u0434\u043a\u043b. \u0440\u0430\u0437\u044a-\u043d\u043e",
+ "UserLockedOutWithName": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c {0} \u0431\u044b\u043b \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d",
+ "UserOfflineFromDevice": "{0} - \u043f\u043e\u0434\u043a\u043b. \u0441 {1} \u0440\u0430\u0437\u044a-\u043d\u043e",
+ "UserStartedPlayingItemWithValues": "{0} - \u0432\u043e\u0441\u043f\u0440. \u00ab{1}\u00bb \u0437\u0430\u043f-\u043d\u043e",
+ "UserStoppedPlayingItemWithValues": "{0} - \u0432\u043e\u0441\u043f\u0440. \u00ab{1}\u00bb \u043e\u0441\u0442-\u043d\u043e",
+ "SubtitleDownloadFailureForItem": "\u0421\u0443\u0431\u0442\u0438\u0442\u0440\u044b \u043a {0} \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c",
+ "HeaderUnidentified": "\u041d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043e",
+ "HeaderImagePrimary": "\u0413\u043e\u043b\u043e\u0432\u043d\u043e\u0439",
+ "HeaderImageBackdrop": "\u0417\u0430\u0434\u043d\u0438\u043a",
+ "HeaderImageLogo": "\u041b\u043e\u0433\u043e\u0442\u0438\u043f",
+ "HeaderUserPrimaryImage": "\u0420\u0438\u0441\u0443\u043d\u043e\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f",
+ "HeaderOverview": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435",
+ "HeaderShortOverview": "\u041a\u0440\u0430\u0442\u043a\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435",
+ "HeaderType": "\u0422\u0438\u043f",
+ "HeaderSeverity": "\u0412\u0430\u0436\u043d\u043e\u0441\u0442\u044c",
+ "HeaderUser": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
+ "HeaderName": "\u0418\u043c\u044f (\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435)",
+ "HeaderDate": "\u0414\u0430\u0442\u0430",
+ "HeaderPremiereDate": "\u0414\u0430\u0442\u0430 \u043f\u0440\u0435\u043c\u044c\u0435\u0440\u044b",
+ "HeaderDateAdded": "\u0414\u0430\u0442\u0430 \u0434\u043e\u0431.",
+ "HeaderReleaseDate": "\u0414\u0430\u0442\u0430 \u0432\u044b\u043f.",
+ "HeaderRuntime": "\u0414\u043b\u0438\u0442.",
+ "HeaderPlayCount": "\u041a\u043e\u043b-\u0432\u043e \u0432\u043e\u0441\u043f\u0440.",
+ "HeaderSeason": "\u0421\u0435\u0437\u043e\u043d",
+ "HeaderSeasonNumber": "\u2116 \u0441\u0435\u0437\u043e\u043d\u0430",
+ "HeaderSeries": "\u0421\u0435\u0440\u0438\u0430\u043b:",
+ "HeaderNetwork": "\u0422\u0435\u043b\u0435\u0441\u0435\u0442\u044c",
+ "HeaderYear": "\u0413\u043e\u0434:",
+ "HeaderYears": "\u0413\u043e\u0434\u044b:",
+ "HeaderParentalRating": "\u0412\u043e\u0437\u0440. \u043a\u0430\u0442.",
+ "HeaderCommunityRating": "\u041e\u0431\u0449. \u043e\u0446\u0435\u043d\u043a\u0430",
+ "HeaderTrailers": "\u0422\u0440\u0435\u0439\u043b.",
+ "HeaderSpecials": "\u0421\u043f\u0435\u0446.",
+ "HeaderGameSystems": "\u0418\u0433\u0440. \u0441\u0438\u0441\u0442\u0435\u043c\u044b",
+ "HeaderPlayers": "\u0418\u0433\u0440\u043e\u043a\u0438:",
+ "HeaderAlbumArtists": "\u0418\u0441\u043f-\u043b\u0438 \u0430\u043b\u044c\u0431\u043e\u043c\u0430",
+ "HeaderAlbums": "\u0410\u043b\u044c\u0431\u043e\u043c\u044b",
+ "HeaderDisc": "\u0414\u0438\u0441\u043a",
+ "HeaderTrack": "\u0414\u043e\u0440-\u043a\u0430",
+ "HeaderAudio": "\u0410\u0443\u0434\u0438\u043e",
+ "HeaderVideo": "\u0412\u0438\u0434\u0435\u043e",
+ "HeaderEmbeddedImage": "\u0412\u043d\u0435\u0434\u0440\u0451\u043d\u043d\u044b\u0439 \u0440\u0438\u0441\u0443\u043d\u043e\u043a",
+ "HeaderResolution": "\u0420\u0430\u0437\u0440.",
+ "HeaderSubtitles": "\u0421\u0443\u0431\u0442.",
+ "HeaderGenres": "\u0416\u0430\u043d\u0440\u044b",
+ "HeaderCountries": "\u0421\u0442\u0440\u0430\u043d\u044b",
+ "HeaderStatus": "\u0421\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435",
+ "HeaderTracks": "\u0414\u043e\u0440-\u043a\u0438",
+ "HeaderMusicArtist": "\u041c\u0443\u0437. \u0438\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c",
+ "HeaderLocked": "\u0417\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e",
+ "HeaderStudios": "\u0421\u0442\u0443\u0434\u0438\u0438",
+ "HeaderActor": "\u0410\u043a\u0442\u0451\u0440\u044b",
+ "HeaderComposer": "\u041a\u043e\u043c\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u044b",
+ "HeaderDirector": "\u0420\u0435\u0436\u0438\u0441\u0441\u0451\u0440\u044b",
+ "HeaderGuestStar": "\u041f\u0440\u0438\u0433\u043b. \u0430\u043a\u0442\u0451\u0440",
+ "HeaderProducer": "\u041f\u0440\u043e\u0434\u044e\u0441\u0435\u0440\u044b",
+ "HeaderWriter": "\u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0441\u0442\u044b",
+ "HeaderParentalRatings": "\u0412\u043e\u0437\u0440\u0430\u0441\u0442\u043d\u0430\u044f \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f",
+ "HeaderCommunityRatings": "\u041e\u0431\u0449. \u043e\u0446\u0435\u043d\u043a\u0438",
+ "StartupEmbyServerIsLoading": "Emby Server \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442\u0441\u044f. \u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0432 \u0431\u043b\u0438\u0436\u0430\u0439\u0448\u0435\u0435 \u0432\u0440\u0435\u043c\u044f."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
new file mode 100644
index 000000000..0631e3fa8
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Prosimo pocakajte podatkovna baza Emby Streznika se posodablja. {0}% koncano.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Izhod",
+ "LabelVisitCommunity": "Obiscite Skupnost",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Dokumentacija",
+ "LabelDeveloperResources": "Vsebine za razvijalce",
+ "LabelBrowseLibrary": "Brskanje po knjiznici",
+ "LabelConfigureServer": "Emby Nastavitve",
+ "LabelRestartServer": "Ponovni Zagon Streznika",
+ "CategorySync": "Sync",
+ "CategoryUser": "Uporabnik",
+ "CategorySystem": "Sistem",
+ "CategoryApplication": "Aplikacija",
+ "CategoryPlugin": "Vticnik",
+ "NotificationOptionPluginError": "Napaka v vticniku",
+ "NotificationOptionApplicationUpdateAvailable": "Na voljo je posodobitev",
+ "NotificationOptionApplicationUpdateInstalled": "Posodobitev je bila namescena",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Vticnik namescen",
+ "NotificationOptionPluginUninstalled": "Vticnik odstranjen",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Predvajanje videa koncano",
+ "NotificationOptionAudioPlaybackStopped": "Predvajanje audia koncano",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Napaka v namestitvi",
+ "NotificationOptionNewLibraryContent": "Dodana nova vsebina",
+ "NotificationOptionNewLibraryContentMultiple": "Dodane nove vsebine",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Zahtevan je ponovni zagon",
+ "ViewTypePlaylists": "Playliste",
+ "ViewTypeMovies": "Filmi",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Igre",
+ "ViewTypeMusic": "Glasba",
+ "ViewTypeMusicGenres": "Zvrsti",
+ "ViewTypeMusicArtists": "Izvajalci",
+ "ViewTypeBoxSets": "Zbirke",
+ "ViewTypeChannels": "Kanali",
+ "ViewTypeLiveTV": "TV v Zivo",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Zadnje Igre",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Priljubljeno",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Zvrsti",
+ "ViewTypeTvResume": "Nadaljuj",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Serije",
+ "ViewTypeTvGenres": "Zvrsti",
+ "ViewTypeTvFavoriteSeries": "Priljubljene Serije",
+ "ViewTypeTvFavoriteEpisodes": "Priljubljene Epizode",
+ "ViewTypeMovieResume": "Nadaljuj",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Filmi",
+ "ViewTypeMovieCollections": "Zbirke",
+ "ViewTypeMovieFavorites": "Priljubljeno",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albumi",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Priljubljeni Albumi",
+ "ViewTypeMusicFavoriteArtists": "Priljubljeni Izvajalci",
+ "ViewTypeMusicFavoriteSongs": "Priljubljene skladbe",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Verzija {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Uporabnik",
+ "HeaderName": "Name",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
new file mode 100644
index 000000000..4a6565aff
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "V\u00e4nligen v\u00e4nta medan databasen p\u00e5 din Emby Server uppgraderas. {0}% klar",
+ "AppDeviceValues": "App: {0}, enhet: {1}",
+ "UserDownloadingItemWithValues": "{0} laddar ned {1}",
+ "FolderTypeMixed": "Blandat inneh\u00e5ll",
+ "FolderTypeMovies": "Filmer",
+ "FolderTypeMusic": "Musik",
+ "FolderTypeAdultVideos": "Inneh\u00e5ll f\u00f6r vuxna",
+ "FolderTypePhotos": "Foton",
+ "FolderTypeMusicVideos": "Musikvideor",
+ "FolderTypeHomeVideos": "Hemvideor",
+ "FolderTypeGames": "Spel",
+ "FolderTypeBooks": "B\u00f6cker",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "\u00c4rv",
+ "HeaderCastCrew": "Rollista & bes\u00e4ttning",
+ "HeaderPeople": "Personer",
+ "ValueSpecialEpisodeName": "Specialavsnitt - {0}",
+ "LabelChapterName": "Kapitel {0}",
+ "NameSeasonNumber": "S\u00e4song {0}",
+ "LabelExit": "Avsluta",
+ "LabelVisitCommunity": "Bes\u00f6k v\u00e5rt diskussionsforum",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api-dokumentation",
+ "LabelDeveloperResources": "Resurser f\u00f6r utvecklare",
+ "LabelBrowseLibrary": "Bl\u00e4ddra i biblioteket",
+ "LabelConfigureServer": "Konfigurera Emby",
+ "LabelRestartServer": "Starta om servern",
+ "CategorySync": "Synkronisera",
+ "CategoryUser": "Anv\u00e4ndare",
+ "CategorySystem": "System",
+ "CategoryApplication": "App",
+ "CategoryPlugin": "Till\u00e4gg",
+ "NotificationOptionPluginError": "Fel uppstod med till\u00e4gget",
+ "NotificationOptionApplicationUpdateAvailable": "Ny programversion tillg\u00e4nglig",
+ "NotificationOptionApplicationUpdateInstalled": "Programuppdatering installerad",
+ "NotificationOptionPluginUpdateInstalled": "Till\u00e4gg har uppdaterats",
+ "NotificationOptionPluginInstalled": "Till\u00e4gg har installerats",
+ "NotificationOptionPluginUninstalled": "Till\u00e4gg har avinstallerats",
+ "NotificationOptionVideoPlayback": "Videouppspelning har p\u00e5b\u00f6rjats",
+ "NotificationOptionAudioPlayback": "Ljuduppspelning har p\u00e5b\u00f6rjats",
+ "NotificationOptionGamePlayback": "Spel har startats",
+ "NotificationOptionVideoPlaybackStopped": "Videouppspelning stoppad",
+ "NotificationOptionAudioPlaybackStopped": "Ljuduppspelning stoppad",
+ "NotificationOptionGamePlaybackStopped": "Spel stoppat",
+ "NotificationOptionTaskFailed": "Schemalagd aktivitet har misslyckats",
+ "NotificationOptionInstallationFailed": "Fel vid installation",
+ "NotificationOptionNewLibraryContent": "Nytt inneh\u00e5ll har tillkommit",
+ "NotificationOptionNewLibraryContentMultiple": "Nytillkommet inneh\u00e5ll finns (flera objekt)",
+ "NotificationOptionCameraImageUploaded": "Kaberabild uppladdad",
+ "NotificationOptionUserLockedOut": "Anv\u00e4ndare har l\u00e5sts ute",
+ "NotificationOptionServerRestartRequired": "Servern m\u00e5ste startas om",
+ "ViewTypePlaylists": "Spellistor",
+ "ViewTypeMovies": "Filmer",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Spel",
+ "ViewTypeMusic": "Musik",
+ "ViewTypeMusicGenres": "Genrer",
+ "ViewTypeMusicArtists": "Artister",
+ "ViewTypeBoxSets": "Samlingar",
+ "ViewTypeChannels": "Kanaler",
+ "ViewTypeLiveTV": "Live-TV",
+ "ViewTypeLiveTvNowPlaying": "Visas nu",
+ "ViewTypeLatestGames": "Senaste spelen",
+ "ViewTypeRecentlyPlayedGames": "Nyligen spelade",
+ "ViewTypeGameFavorites": "Favoriter",
+ "ViewTypeGameSystems": "Spelsystem",
+ "ViewTypeGameGenres": "Genrer",
+ "ViewTypeTvResume": "\u00c5teruppta",
+ "ViewTypeTvNextUp": "N\u00e4stkommande",
+ "ViewTypeTvLatest": "Nytillkommet",
+ "ViewTypeTvShowSeries": "Serier",
+ "ViewTypeTvGenres": "Genrer",
+ "ViewTypeTvFavoriteSeries": "Favoritserier",
+ "ViewTypeTvFavoriteEpisodes": "Favoritavsnitt",
+ "ViewTypeMovieResume": "\u00c5teruppta",
+ "ViewTypeMovieLatest": "Nytillkommet",
+ "ViewTypeMovieMovies": "Filmer",
+ "ViewTypeMovieCollections": "Samlingar",
+ "ViewTypeMovieFavorites": "Favoriter",
+ "ViewTypeMovieGenres": "Genrer",
+ "ViewTypeMusicLatest": "Nytillkommet",
+ "ViewTypeMusicPlaylists": "Spellistor",
+ "ViewTypeMusicAlbums": "Album",
+ "ViewTypeMusicAlbumArtists": "Albumartister",
+ "HeaderOtherDisplaySettings": "Visningsalternativ",
+ "ViewTypeMusicSongs": "L\u00e5tar",
+ "ViewTypeMusicFavorites": "Favoriter",
+ "ViewTypeMusicFavoriteAlbums": "Favoritalbum",
+ "ViewTypeMusicFavoriteArtists": "Favoritartister",
+ "ViewTypeMusicFavoriteSongs": "Favoritl\u00e5tar",
+ "ViewTypeFolders": "Mappar",
+ "ViewTypeLiveTvRecordingGroups": "Inspelningar",
+ "ViewTypeLiveTvChannels": "Kanaler",
+ "ScheduledTaskFailedWithName": "{0} misslyckades",
+ "LabelRunningTimeValue": "Speltid: {0}",
+ "ScheduledTaskStartedWithName": "{0} startad",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} installerades",
+ "PluginUpdatedWithName": "{0} uppdaterades",
+ "PluginUninstalledWithName": "{0} avinstallerades",
+ "ItemAddedWithName": "{0} lades till i biblioteket",
+ "ItemRemovedWithName": "{0} togs bort ur biblioteket",
+ "LabelIpAddressValue": "IP-adress: {0}",
+ "DeviceOnlineWithName": "{0} \u00e4r ansluten",
+ "UserOnlineFromDevice": "{0} \u00e4r uppkopplad fr\u00e5n {1}",
+ "ProviderValue": "K\u00e4lla: {0}",
+ "SubtitlesDownloadedForItem": "Undertexter har laddats ner f\u00f6r {0}",
+ "UserConfigurationUpdatedWithName": "Anv\u00e4ndarinst\u00e4llningarna f\u00f6r {0} har uppdaterats",
+ "UserCreatedWithName": "Anv\u00e4ndaren {0} har skapats",
+ "UserPasswordChangedWithName": "L\u00f6senordet f\u00f6r {0} har \u00e4ndrats",
+ "UserDeletedWithName": "Anv\u00e4ndaren {0} har tagits bort",
+ "MessageServerConfigurationUpdated": "Server konfigurationen har uppdaterats",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Serverinst\u00e4llningarnas del {0} ar uppdaterats",
+ "MessageApplicationUpdated": "Emby Server har uppdaterats",
+ "FailedLoginAttemptWithUserName": "Misslyckat inloggningsf\u00f6rs\u00f6k fr\u00e5n {0}",
+ "AuthenticationSucceededWithUserName": "{0} har autentiserats",
+ "DeviceOfflineWithName": "{0} har avbrutit anslutningen",
+ "UserLockedOutWithName": "Anv\u00e4ndare {0} har l\u00e5sts ute",
+ "UserOfflineFromDevice": "{0} har avbrutit anslutningen fr\u00e5n {1}",
+ "UserStartedPlayingItemWithValues": "{0} har p\u00e5b\u00f6rjat uppspelning av {1}",
+ "UserStoppedPlayingItemWithValues": "{0} har avslutat uppspelning av {1}",
+ "SubtitleDownloadFailureForItem": "Nerladdning av undertexter f\u00f6r {0} misslyckades",
+ "HeaderUnidentified": "Oidentifierad",
+ "HeaderImagePrimary": "Huvudbild",
+ "HeaderImageBackdrop": "Bakgrundsbild",
+ "HeaderImageLogo": "Logotyp",
+ "HeaderUserPrimaryImage": "Anv\u00e4ndarbild",
+ "HeaderOverview": "\u00d6versikt",
+ "HeaderShortOverview": "Kort \u00f6versikt",
+ "HeaderType": "Typ",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "Anv\u00e4ndare",
+ "HeaderName": "Namn",
+ "HeaderDate": "Datum",
+ "HeaderPremiereDate": "Premi\u00e4rdatum",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Premi\u00e4rdatum:",
+ "HeaderRuntime": "Speltid",
+ "HeaderPlayCount": "Antal spelningar",
+ "HeaderSeason": "S\u00e4song",
+ "HeaderSeasonNumber": "S\u00e4songsnummer:",
+ "HeaderSeries": "Serie:",
+ "HeaderNetwork": "TV-bolag",
+ "HeaderYear": "\u00c5r:",
+ "HeaderYears": "\u00c5r:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Anv\u00e4ndaromd\u00f6me",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specialavsnitt",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Spelare:",
+ "HeaderAlbumArtists": "Albumartister",
+ "HeaderAlbums": "Album",
+ "HeaderDisc": "Skiva",
+ "HeaderTrack": "Sp\u00e5r",
+ "HeaderAudio": "Ljud",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Infogad bild",
+ "HeaderResolution": "Uppl\u00f6sning",
+ "HeaderSubtitles": "Undertexter",
+ "HeaderGenres": "Genrer",
+ "HeaderCountries": "L\u00e4nder",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "Sp\u00e5r",
+ "HeaderMusicArtist": "Musikartist",
+ "HeaderLocked": "L\u00e5st",
+ "HeaderStudios": "Studior",
+ "HeaderActor": "Sk\u00e5despelare",
+ "HeaderComposer": "Komposit\u00f6rer",
+ "HeaderDirector": "Regiss\u00f6r",
+ "HeaderGuestStar": "G\u00e4startist",
+ "HeaderProducer": "Producenter",
+ "HeaderWriter": "F\u00f6rfattare",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server startar. V\u00e4nligen f\u00f6rs\u00f6k igen om en kort stund."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
new file mode 100644
index 000000000..a691e9d02
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Cikis",
+ "LabelVisitCommunity": "Bizi Ziyaret Edin",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "K\u00fct\u00fcphane",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Server Yeniden Baslat",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Uygulamalar",
+ "CategoryPlugin": "Eklenti",
+ "NotificationOptionPluginError": "Eklenti Ba\u015far\u0131s\u0131z",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Sunucu yeniden ba\u015flat\u0131lmal\u0131",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Versiyon {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Durum",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json
new file mode 100644
index 000000000..0dc6afe8a
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/uk.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "\u0424\u0456\u043b\u044c\u043c\u0438",
+ "FolderTypeMusic": "\u041c\u0443\u0437\u0438\u043a\u0430",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "\u0421\u0432\u0456\u0442\u043b\u0438\u043d\u0438",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "\u0406\u0433\u0440\u0438",
+ "FolderTypeBooks": "\u041a\u043d\u0438\u0433\u0438",
+ "FolderTypeTvShows": "\u0422\u0411",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u0412\u0438\u0439\u0442\u0438",
+ "LabelVisitCommunity": "Visit Community",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Browse Library",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0438 \u0441\u0435\u0440\u0432\u0435\u0440",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "\u0424\u0456\u043b\u044c\u043c\u0438",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "\u041a\u043e\u043b\u0435\u043a\u0446\u0456\u0457",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "\u041e\u0441\u0442\u0430\u043d\u043d\u0456 \u0456\u0433\u0440\u0438",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "\u0424\u0456\u043b\u044c\u043c\u0438",
+ "ViewTypeMovieCollections": "\u041a\u043e\u043b\u0435\u043a\u0446\u0456\u0457",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "\u041e\u0441\u0442\u0430\u043d\u043d\u0456",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "\u0421\u0435\u0437\u043e\u043d",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "\u0422\u0440\u0435\u0439\u043b\u0435\u0440\u0438",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "\u0410\u043b\u044c\u0431\u043e\u043c\u0438",
+ "HeaderDisc": "\u0414\u0438\u0441\u043a",
+ "HeaderTrack": "\u0414\u043e\u0440\u0456\u0436\u043a\u0430",
+ "HeaderAudio": "\u0410\u0443\u0434\u0456\u043e",
+ "HeaderVideo": "\u0412\u0456\u0434\u0435\u043e",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Status",
+ "HeaderTracks": "\u0414\u043e\u0440\u0456\u0436\u043a\u0438",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "\u0421\u0442\u0443\u0434\u0456\u0457",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
new file mode 100644
index 000000000..6ea1d1d3f
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "Cast & Crew",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "Tho\u00e1t",
+ "LabelVisitCommunity": "Gh\u00e9 th\u0103m trang C\u1ed9ng \u0111\u1ed3ng",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api Documentation",
+ "LabelDeveloperResources": "Developer Resources",
+ "LabelBrowseLibrary": "Duy\u1ec7t th\u01b0 vi\u1ec7n",
+ "LabelConfigureServer": "Configure Emby",
+ "LabelRestartServer": "Kh\u1edfi \u0111\u1ed9ng l\u1ea1i m\u00e1y ch\u1ee7",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "Version {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "T\u00ean",
+ "HeaderDate": "Ng\u00e0y",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "Tr\u1ea1ng th\u00e1i",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
new file mode 100644
index 000000000..580832a9e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App\uff1a {0}\uff0c\u8bbe\u5907\uff1a {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\u6df7\u5408\u5185\u5bb9",
+ "FolderTypeMovies": "\u7535\u5f71",
+ "FolderTypeMusic": "\u97f3\u4e50",
+ "FolderTypeAdultVideos": "\u6210\u4eba\u89c6\u9891",
+ "FolderTypePhotos": "\u56fe\u7247",
+ "FolderTypeMusicVideos": "\u97f3\u4e50\u89c6\u9891",
+ "FolderTypeHomeVideos": "\u5bb6\u5ead\u89c6\u9891",
+ "FolderTypeGames": "\u6e38\u620f",
+ "FolderTypeBooks": "\u4e66\u7c4d",
+ "FolderTypeTvShows": "\u7535\u89c6",
+ "FolderTypeInherit": "\u7ee7\u627f",
+ "HeaderCastCrew": "\u6f14\u804c\u4eba\u5458",
+ "HeaderPeople": "\u4eba\u7269",
+ "ValueSpecialEpisodeName": "\u7279\u522b - {0}",
+ "LabelChapterName": "\u7ae0\u8282 {0}",
+ "NameSeasonNumber": "\u5b63 {0}",
+ "LabelExit": "\u9000\u51fa",
+ "LabelVisitCommunity": "\u8bbf\u95ee\u793e\u533a",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "API\u6587\u6863",
+ "LabelDeveloperResources": "\u5f00\u53d1\u8005\u8d44\u6e90",
+ "LabelBrowseLibrary": "\u6d4f\u89c8\u5a92\u4f53\u5e93",
+ "LabelConfigureServer": "\u914d\u7f6eEmby",
+ "LabelRestartServer": "\u91cd\u542f\u670d\u52a1\u5668",
+ "CategorySync": "\u540c\u6b65",
+ "CategoryUser": "\u7528\u6237",
+ "CategorySystem": "\u7cfb\u7edf",
+ "CategoryApplication": "\u5e94\u7528\u7a0b\u5e8f",
+ "CategoryPlugin": "\u63d2\u4ef6",
+ "NotificationOptionPluginError": "\u63d2\u4ef6\u5931\u8d25",
+ "NotificationOptionApplicationUpdateAvailable": "\u6709\u53ef\u7528\u7684\u5e94\u7528\u7a0b\u5e8f\u66f4\u65b0",
+ "NotificationOptionApplicationUpdateInstalled": "\u5e94\u7528\u7a0b\u5e8f\u66f4\u65b0\u5df2\u5b89\u88c5",
+ "NotificationOptionPluginUpdateInstalled": "\u63d2\u4ef6\u66f4\u65b0\u5df2\u5b89\u88c5",
+ "NotificationOptionPluginInstalled": "\u63d2\u4ef6\u5df2\u5b89\u88c5",
+ "NotificationOptionPluginUninstalled": "\u63d2\u4ef6\u5df2\u5378\u8f7d",
+ "NotificationOptionVideoPlayback": "\u89c6\u9891\u5f00\u59cb\u64ad\u653e",
+ "NotificationOptionAudioPlayback": "\u97f3\u9891\u5f00\u59cb\u64ad\u653e",
+ "NotificationOptionGamePlayback": "\u6e38\u620f\u5f00\u59cb",
+ "NotificationOptionVideoPlaybackStopped": "\u89c6\u9891\u64ad\u653e\u505c\u6b62",
+ "NotificationOptionAudioPlaybackStopped": "\u97f3\u9891\u64ad\u653e\u505c\u6b62",
+ "NotificationOptionGamePlaybackStopped": "\u6e38\u620f\u505c\u6b62",
+ "NotificationOptionTaskFailed": "\u8ba1\u5212\u4efb\u52a1\u5931\u8d25",
+ "NotificationOptionInstallationFailed": "\u5b89\u88c5\u5931\u8d25",
+ "NotificationOptionNewLibraryContent": "\u6dfb\u52a0\u65b0\u5185\u5bb9",
+ "NotificationOptionNewLibraryContentMultiple": "\u65b0\u7684\u5185\u5bb9\u52a0\u5165\uff08\u591a\u4e2a\uff09",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "\u9700\u8981\u91cd\u65b0\u542f\u52a8\u670d\u52a1\u5668",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "\u7535\u5f71",
+ "ViewTypeTvShows": "\u7535\u89c6",
+ "ViewTypeGames": "\u6e38\u620f",
+ "ViewTypeMusic": "\u97f3\u4e50",
+ "ViewTypeMusicGenres": "\u98ce\u683c",
+ "ViewTypeMusicArtists": "\u827a\u672f\u5bb6",
+ "ViewTypeBoxSets": "\u5408\u96c6",
+ "ViewTypeChannels": "\u9891\u9053",
+ "ViewTypeLiveTV": "\u7535\u89c6\u76f4\u64ad",
+ "ViewTypeLiveTvNowPlaying": "\u73b0\u5728\u64ad\u653e",
+ "ViewTypeLatestGames": "\u6700\u65b0\u6e38\u620f",
+ "ViewTypeRecentlyPlayedGames": "\u6700\u8fd1\u64ad\u653e",
+ "ViewTypeGameFavorites": "\u6211\u7684\u6700\u7231",
+ "ViewTypeGameSystems": "\u6e38\u620f\u7cfb\u7edf",
+ "ViewTypeGameGenres": "\u98ce\u683c",
+ "ViewTypeTvResume": "\u6062\u590d\u64ad\u653e",
+ "ViewTypeTvNextUp": "\u4e0b\u4e00\u4e2a",
+ "ViewTypeTvLatest": "\u6700\u65b0",
+ "ViewTypeTvShowSeries": "\u7535\u89c6\u5267",
+ "ViewTypeTvGenres": "\u98ce\u683c",
+ "ViewTypeTvFavoriteSeries": "\u6700\u559c\u6b22\u7684\u7535\u89c6\u5267",
+ "ViewTypeTvFavoriteEpisodes": "\u6700\u559c\u6b22\u7684\u5267\u96c6",
+ "ViewTypeMovieResume": "\u6062\u590d\u64ad\u653e",
+ "ViewTypeMovieLatest": "\u6700\u65b0",
+ "ViewTypeMovieMovies": "\u7535\u5f71",
+ "ViewTypeMovieCollections": "\u5408\u96c6",
+ "ViewTypeMovieFavorites": "\u6536\u85cf\u5939",
+ "ViewTypeMovieGenres": "\u98ce\u683c",
+ "ViewTypeMusicLatest": "\u6700\u65b0",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "\u4e13\u8f91",
+ "ViewTypeMusicAlbumArtists": "\u4e13\u8f91\u827a\u672f\u5bb6",
+ "HeaderOtherDisplaySettings": "\u663e\u793a\u8bbe\u7f6e",
+ "ViewTypeMusicSongs": "\u6b4c\u66f2",
+ "ViewTypeMusicFavorites": "\u6211\u7684\u6700\u7231",
+ "ViewTypeMusicFavoriteAlbums": "\u6700\u7231\u7684\u4e13\u8f91",
+ "ViewTypeMusicFavoriteArtists": "\u6700\u7231\u7684\u827a\u672f\u5bb6",
+ "ViewTypeMusicFavoriteSongs": "\u6700\u7231\u7684\u6b4c\u66f2",
+ "ViewTypeFolders": "\u6587\u4ef6\u5939",
+ "ViewTypeLiveTvRecordingGroups": "\u5f55\u5236",
+ "ViewTypeLiveTvChannels": "\u9891\u9053",
+ "ScheduledTaskFailedWithName": "{0} \u5931\u8d25",
+ "LabelRunningTimeValue": "\u8fd0\u884c\u65f6\u95f4\uff1a {0}",
+ "ScheduledTaskStartedWithName": "{0} \u5f00\u59cb",
+ "VersionNumber": "\u7248\u672c {0}",
+ "PluginInstalledWithName": "{0} \u5df2\u5b89\u88c5",
+ "PluginUpdatedWithName": "{0} \u5df2\u66f4\u65b0",
+ "PluginUninstalledWithName": "{0} \u5df2\u5378\u8f7d",
+ "ItemAddedWithName": "{0} \u5df2\u6dfb\u52a0\u5230\u5a92\u4f53\u5e93",
+ "ItemRemovedWithName": "{0} \u5df2\u4ece\u5a92\u4f53\u5e93\u4e2d\u79fb\u9664",
+ "LabelIpAddressValue": "Ip \u5730\u5740\uff1a {0}",
+ "DeviceOnlineWithName": "{0} \u5df2\u8fde\u63a5",
+ "UserOnlineFromDevice": "{0} \u5728\u7ebf\uff0c\u6765\u81ea {1}",
+ "ProviderValue": "\u63d0\u4f9b\u8005\uff1a {0}",
+ "SubtitlesDownloadedForItem": "\u5df2\u4e3a {0} \u4e0b\u8f7d\u4e86\u5b57\u5e55",
+ "UserConfigurationUpdatedWithName": "\u7528\u6237\u914d\u7f6e\u5df2\u66f4\u65b0\u4e3a {0}",
+ "UserCreatedWithName": "\u7528\u6237 {0} \u5df2\u88ab\u521b\u5efa",
+ "UserPasswordChangedWithName": "\u5df2\u4e3a\u7528\u6237 {0} \u66f4\u6539\u5bc6\u7801",
+ "UserDeletedWithName": "\u7528\u6237 {0} \u5df2\u88ab\u5220\u9664",
+ "MessageServerConfigurationUpdated": "\u670d\u52a1\u5668\u914d\u7f6e\u5df2\u66f4\u65b0",
+ "MessageNamedServerConfigurationUpdatedWithValue": "\u670d\u52a1\u5668\u914d\u7f6e {0} \u90e8\u5206\u5df2\u66f4\u65b0",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "\u5931\u8d25\u7684\u767b\u5f55\u5c1d\u8bd5\uff0c\u6765\u81ea {0}",
+ "AuthenticationSucceededWithUserName": "{0} \u6210\u529f\u88ab\u6388\u6743",
+ "DeviceOfflineWithName": "{0} \u5df2\u65ad\u5f00\u8fde\u63a5",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} \u5df2\u4ece {1} \u65ad\u5f00\u8fde\u63a5",
+ "UserStartedPlayingItemWithValues": "{0} \u5f00\u59cb\u64ad\u653e {1}",
+ "UserStoppedPlayingItemWithValues": "{0} \u505c\u6b62\u64ad\u653e {1}",
+ "SubtitleDownloadFailureForItem": "\u4e3a {0} \u4e0b\u8f7d\u5b57\u5e55\u5931\u8d25",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "\u7528\u6237",
+ "HeaderName": "\u540d\u5b57",
+ "HeaderDate": "\u65e5\u671f",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "\u53d1\u884c\u65e5\u671f",
+ "HeaderRuntime": "\u64ad\u653e\u65f6\u95f4",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "\u5b63",
+ "HeaderSeasonNumber": "\u591a\u5c11\u5b63",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "\u7f51\u7edc",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "\u516c\u4f17\u8bc4\u5206",
+ "HeaderTrailers": "\u9884\u544a\u7247",
+ "HeaderSpecials": "\u7279\u96c6",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "\u4e13\u8f91",
+ "HeaderDisc": "\u5149\u76d8",
+ "HeaderTrack": "\u97f3\u8f68",
+ "HeaderAudio": "\u97f3\u9891",
+ "HeaderVideo": "\u89c6\u9891",
+ "HeaderEmbeddedImage": "\u5d4c\u5165\u5f0f\u56fe\u50cf",
+ "HeaderResolution": "\u5206\u8fa8\u7387",
+ "HeaderSubtitles": "\u5b57\u5e55",
+ "HeaderGenres": "\u98ce\u683c",
+ "HeaderCountries": "\u56fd\u5bb6",
+ "HeaderStatus": "\u72b6\u6001",
+ "HeaderTracks": "\u97f3\u8f68",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "\u5de5\u4f5c\u5ba4",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "\u5bb6\u957f\u5206\u7ea7",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
new file mode 100644
index 000000000..a70e7a003
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "Please wait while your Emby Server database is upgraded. {0}% complete.",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "\u6df7\u5408\u5167\u5bb9",
+ "FolderTypeMovies": "\u96fb\u5f71",
+ "FolderTypeMusic": "\u97f3\u6a02",
+ "FolderTypeAdultVideos": "\u6210\u4eba\u5f71\u7247",
+ "FolderTypePhotos": "\u76f8\u7247",
+ "FolderTypeMusicVideos": "MV",
+ "FolderTypeHomeVideos": "\u500b\u4eba\u5f71\u7247",
+ "FolderTypeGames": "\u904a\u6232",
+ "FolderTypeBooks": "\u66f8\u85c9",
+ "FolderTypeTvShows": "\u96fb\u8996\u7bc0\u76ee",
+ "FolderTypeInherit": "\u7e7c\u627f",
+ "HeaderCastCrew": "\u6f14\u54e1\u9663\u5bb9",
+ "HeaderPeople": "\u4eba\u7269",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "\u5287\u96c6\u5b63\u5ea6 {0}",
+ "LabelExit": "\u96e2\u958b",
+ "LabelVisitCommunity": "\u8a2a\u554f\u8a0e\u8ad6\u5340",
+ "LabelGithub": "Github",
+ "LabelApiDocumentation": "Api \u6587\u4ef6",
+ "LabelDeveloperResources": "\u958b\u767c\u8005\u8cc7\u6e90",
+ "LabelBrowseLibrary": "\u700f\u89bd\u8cc7\u6599\u5eab",
+ "LabelConfigureServer": "\u8a2d\u7f6e Emby",
+ "LabelRestartServer": "\u91cd\u65b0\u555f\u52d5\u4f3a\u670d\u5668",
+ "CategorySync": "\u540c\u6b65",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "\u9700\u8981\u91cd\u65b0\u555f\u52d5",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "\u904a\u6232",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "\u85cf\u54c1",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "Live TV",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "\u6700\u8fd1\u904a\u6232",
+ "ViewTypeRecentlyPlayedGames": "\u6700\u8fd1\u64ad\u653e",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "\u904a\u6232\u7cfb\u7d71",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "\u96fb\u8996\u5287",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "\u6211\u7684\u6700\u611b\u96fb\u8996\u5287",
+ "ViewTypeTvFavoriteEpisodes": "\u6211\u7684\u6700\u611b\u5287\u96c6",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "\u85cf\u54c1",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "\u6b4c\u66f2",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "\u6211\u7684\u6700\u611b\u6b4c\u66f2",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "\u7248\u672c {0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "\u5df2\u7d93\u70ba {0} \u4e0b\u8f09\u4e86\u5b57\u5e55",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "\u70ba {0} \u4e0b\u8f09\u5b57\u5e55\u5931\u6557",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "\u540d\u7a31",
+ "HeaderDate": "\u65e5\u671f",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "\u5287\u96c6\u5b63\u5ea6",
+ "HeaderSeasonNumber": "\u5287\u96c6\u5b63\u5ea6\u6578\u76ee",
+ "HeaderSeries": "\u96fb\u8996\u5287\uff1a",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "\u904a\u6232\u7cfb\u7d71",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "\u97f3\u8a0a",
+ "HeaderVideo": "\u5f71\u7247",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "\u5b57\u5e55",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "\u72c0\u614b",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "\u6b4c\u624b",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "\u7279\u7d04\u660e\u661f",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
new file mode 100644
index 000000000..b711aab1f
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -0,0 +1,178 @@
+{
+ "DbUpgradeMessage": "\u8acb\u7a0d\u5019\uff0cEmby\u4f3a\u670d\u5668\u8cc7\u6599\u5eab\u6b63\u5728\u66f4\u65b0...\uff08\u5df2\u5b8c\u6210{0}%\uff09",
+ "AppDeviceValues": "App: {0}, Device: {1}",
+ "UserDownloadingItemWithValues": "{0} is downloading {1}",
+ "FolderTypeMixed": "Mixed content",
+ "FolderTypeMovies": "Movies",
+ "FolderTypeMusic": "Music",
+ "FolderTypeAdultVideos": "Adult videos",
+ "FolderTypePhotos": "Photos",
+ "FolderTypeMusicVideos": "Music videos",
+ "FolderTypeHomeVideos": "Home videos",
+ "FolderTypeGames": "Games",
+ "FolderTypeBooks": "Books",
+ "FolderTypeTvShows": "TV",
+ "FolderTypeInherit": "Inherit",
+ "HeaderCastCrew": "\u62cd\u651d\u4eba\u54e1\u53ca\u6f14\u54e1",
+ "HeaderPeople": "People",
+ "ValueSpecialEpisodeName": "Special - {0}",
+ "LabelChapterName": "Chapter {0}",
+ "NameSeasonNumber": "Season {0}",
+ "LabelExit": "\u96e2\u958b",
+ "LabelVisitCommunity": "\u8a2a\u554f\u793e\u7fa4",
+ "LabelGithub": "GitHub",
+ "LabelApiDocumentation": "API\u8aaa\u660e\u6587\u4ef6",
+ "LabelDeveloperResources": "\u958b\u767c\u4eba\u54e1\u5c08\u5340",
+ "LabelBrowseLibrary": "\u700f\u89bd\u5a92\u9ad4\u6ac3",
+ "LabelConfigureServer": "Emby\u8a2d\u5b9a",
+ "LabelRestartServer": "\u91cd\u65b0\u555f\u52d5\u4f3a\u670d\u5668",
+ "CategorySync": "Sync",
+ "CategoryUser": "User",
+ "CategorySystem": "System",
+ "CategoryApplication": "Application",
+ "CategoryPlugin": "Plugin",
+ "NotificationOptionPluginError": "Plugin failure",
+ "NotificationOptionApplicationUpdateAvailable": "Application update available",
+ "NotificationOptionApplicationUpdateInstalled": "Application update installed",
+ "NotificationOptionPluginUpdateInstalled": "Plugin update installed",
+ "NotificationOptionPluginInstalled": "Plugin installed",
+ "NotificationOptionPluginUninstalled": "Plugin uninstalled",
+ "NotificationOptionVideoPlayback": "Video playback started",
+ "NotificationOptionAudioPlayback": "Audio playback started",
+ "NotificationOptionGamePlayback": "Game playback started",
+ "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback stopped",
+ "NotificationOptionGamePlaybackStopped": "Game playback stopped",
+ "NotificationOptionTaskFailed": "Scheduled task failure",
+ "NotificationOptionInstallationFailed": "Installation failure",
+ "NotificationOptionNewLibraryContent": "New content added",
+ "NotificationOptionNewLibraryContentMultiple": "New content added (multiple)",
+ "NotificationOptionCameraImageUploaded": "Camera image uploaded",
+ "NotificationOptionUserLockedOut": "User locked out",
+ "NotificationOptionServerRestartRequired": "Server restart required",
+ "ViewTypePlaylists": "Playlists",
+ "ViewTypeMovies": "Movies",
+ "ViewTypeTvShows": "TV",
+ "ViewTypeGames": "Games",
+ "ViewTypeMusic": "Music",
+ "ViewTypeMusicGenres": "Genres",
+ "ViewTypeMusicArtists": "Artists",
+ "ViewTypeBoxSets": "Collections",
+ "ViewTypeChannels": "Channels",
+ "ViewTypeLiveTV": "\u96fb\u8996",
+ "ViewTypeLiveTvNowPlaying": "Now Airing",
+ "ViewTypeLatestGames": "Latest Games",
+ "ViewTypeRecentlyPlayedGames": "Recently Played",
+ "ViewTypeGameFavorites": "Favorites",
+ "ViewTypeGameSystems": "Game Systems",
+ "ViewTypeGameGenres": "Genres",
+ "ViewTypeTvResume": "Resume",
+ "ViewTypeTvNextUp": "Next Up",
+ "ViewTypeTvLatest": "Latest",
+ "ViewTypeTvShowSeries": "Series",
+ "ViewTypeTvGenres": "Genres",
+ "ViewTypeTvFavoriteSeries": "Favorite Series",
+ "ViewTypeTvFavoriteEpisodes": "Favorite Episodes",
+ "ViewTypeMovieResume": "Resume",
+ "ViewTypeMovieLatest": "Latest",
+ "ViewTypeMovieMovies": "Movies",
+ "ViewTypeMovieCollections": "Collections",
+ "ViewTypeMovieFavorites": "Favorites",
+ "ViewTypeMovieGenres": "Genres",
+ "ViewTypeMusicLatest": "Latest",
+ "ViewTypeMusicPlaylists": "Playlists",
+ "ViewTypeMusicAlbums": "Albums",
+ "ViewTypeMusicAlbumArtists": "Album Artists",
+ "HeaderOtherDisplaySettings": "Display Settings",
+ "ViewTypeMusicSongs": "Songs",
+ "ViewTypeMusicFavorites": "Favorites",
+ "ViewTypeMusicFavoriteAlbums": "Favorite Albums",
+ "ViewTypeMusicFavoriteArtists": "Favorite Artists",
+ "ViewTypeMusicFavoriteSongs": "Favorite Songs",
+ "ViewTypeFolders": "Folders",
+ "ViewTypeLiveTvRecordingGroups": "Recordings",
+ "ViewTypeLiveTvChannels": "Channels",
+ "ScheduledTaskFailedWithName": "{0} failed",
+ "LabelRunningTimeValue": "Running time: {0}",
+ "ScheduledTaskStartedWithName": "{0} started",
+ "VersionNumber": "\u7248\u672c{0}",
+ "PluginInstalledWithName": "{0} was installed",
+ "PluginUpdatedWithName": "{0} was updated",
+ "PluginUninstalledWithName": "{0} was uninstalled",
+ "ItemAddedWithName": "{0} was added to the library",
+ "ItemRemovedWithName": "{0} was removed from the library",
+ "LabelIpAddressValue": "Ip address: {0}",
+ "DeviceOnlineWithName": "{0} is connected",
+ "UserOnlineFromDevice": "{0} is online from {1}",
+ "ProviderValue": "Provider: {0}",
+ "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}",
+ "UserConfigurationUpdatedWithName": "User configuration has been updated for {0}",
+ "UserCreatedWithName": "User {0} has been created",
+ "UserPasswordChangedWithName": "Password has been changed for user {0}",
+ "UserDeletedWithName": "User {0} has been deleted",
+ "MessageServerConfigurationUpdated": "Server configuration has been updated",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
+ "MessageApplicationUpdated": "Emby Server has been updated",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
+ "AuthenticationSucceededWithUserName": "{0} successfully authenticated",
+ "DeviceOfflineWithName": "{0} has disconnected",
+ "UserLockedOutWithName": "User {0} has been locked out",
+ "UserOfflineFromDevice": "{0} has disconnected from {1}",
+ "UserStartedPlayingItemWithValues": "{0} has started playing {1}",
+ "UserStoppedPlayingItemWithValues": "{0} has stopped playing {1}",
+ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
+ "HeaderUnidentified": "Unidentified",
+ "HeaderImagePrimary": "Primary",
+ "HeaderImageBackdrop": "Backdrop",
+ "HeaderImageLogo": "Logo",
+ "HeaderUserPrimaryImage": "User Image",
+ "HeaderOverview": "Overview",
+ "HeaderShortOverview": "Short Overview",
+ "HeaderType": "Type",
+ "HeaderSeverity": "Severity",
+ "HeaderUser": "User",
+ "HeaderName": "Name",
+ "HeaderDate": "Date",
+ "HeaderPremiereDate": "Premiere Date",
+ "HeaderDateAdded": "Date Added",
+ "HeaderReleaseDate": "Release date",
+ "HeaderRuntime": "Runtime",
+ "HeaderPlayCount": "Play Count",
+ "HeaderSeason": "Season",
+ "HeaderSeasonNumber": "Season number",
+ "HeaderSeries": "Series:",
+ "HeaderNetwork": "Network",
+ "HeaderYear": "Year:",
+ "HeaderYears": "Years:",
+ "HeaderParentalRating": "Parental Rating",
+ "HeaderCommunityRating": "Community rating",
+ "HeaderTrailers": "Trailers",
+ "HeaderSpecials": "Specials",
+ "HeaderGameSystems": "Game Systems",
+ "HeaderPlayers": "Players:",
+ "HeaderAlbumArtists": "Album Artists",
+ "HeaderAlbums": "Albums",
+ "HeaderDisc": "Disc",
+ "HeaderTrack": "Track",
+ "HeaderAudio": "Audio",
+ "HeaderVideo": "Video",
+ "HeaderEmbeddedImage": "Embedded image",
+ "HeaderResolution": "Resolution",
+ "HeaderSubtitles": "Subtitles",
+ "HeaderGenres": "Genres",
+ "HeaderCountries": "Countries",
+ "HeaderStatus": "\u72c0\u614b",
+ "HeaderTracks": "Tracks",
+ "HeaderMusicArtist": "Music artist",
+ "HeaderLocked": "Locked",
+ "HeaderStudios": "Studios",
+ "HeaderActor": "Actors",
+ "HeaderComposer": "Composers",
+ "HeaderDirector": "Directors",
+ "HeaderGuestStar": "Guest star",
+ "HeaderProducer": "Producers",
+ "HeaderWriter": "Writers",
+ "HeaderParentalRatings": "Parental Ratings",
+ "HeaderCommunityRatings": "Community ratings",
+ "StartupEmbyServerIsLoading": "Emby Server is loading. Please try again shortly."
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
new file mode 100644
index 000000000..120f445c2
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -0,0 +1,433 @@
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Reflection;
+
+namespace Emby.Server.Implementations.Localization
+{
+ /// <summary>
+ /// Class LocalizationManager
+ /// </summary>
+ public class LocalizationManager : ILocalizationManager
+ {
+ /// <summary>
+ /// The _configuration manager
+ /// </summary>
+ private readonly IServerConfigurationManager _configurationManager;
+
+ /// <summary>
+ /// The us culture
+ /// </summary>
+ private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+ private readonly ConcurrentDictionary<string, Dictionary<string, ParentalRating>> _allParentalRatings =
+ new ConcurrentDictionary<string, Dictionary<string, ParentalRating>>(StringComparer.OrdinalIgnoreCase);
+
+ private readonly IFileSystem _fileSystem;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly ILogger _logger;
+ private readonly IAssemblyInfo _assemblyInfo;
+ private readonly ITextLocalizer _textLocalizer;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LocalizationManager" /> class.
+ /// </summary>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="jsonSerializer">The json serializer.</param>
+ public LocalizationManager(IServerConfigurationManager configurationManager, IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, IAssemblyInfo assemblyInfo, ITextLocalizer textLocalizer)
+ {
+ _configurationManager = configurationManager;
+ _fileSystem = fileSystem;
+ _jsonSerializer = jsonSerializer;
+ _logger = logger;
+ _assemblyInfo = assemblyInfo;
+ _textLocalizer = textLocalizer;
+
+ ExtractAll();
+ }
+
+ private void ExtractAll()
+ {
+ var type = GetType();
+ var resourcePath = type.Namespace + ".Ratings.";
+
+ var localizationPath = LocalizationPath;
+
+ _fileSystem.CreateDirectory(localizationPath);
+
+ var existingFiles = GetRatingsFiles(localizationPath)
+ .Select(Path.GetFileName)
+ .ToList();
+
+ // Extract from the assembly
+ foreach (var resource in _assemblyInfo
+ .GetManifestResourceNames(type)
+ .Where(i => i.StartsWith(resourcePath)))
+ {
+ var filename = "ratings-" + resource.Substring(resourcePath.Length);
+
+ if (!existingFiles.Contains(filename))
+ {
+ using (var stream = _assemblyInfo.GetManifestResourceStream(type, resource))
+ {
+ var target = Path.Combine(localizationPath, filename);
+ _logger.Info("Extracting ratings to {0}", target);
+
+ using (var fs = _fileSystem.GetFileStream(target, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read))
+ {
+ stream.CopyTo(fs);
+ }
+ }
+ }
+ }
+
+ foreach (var file in GetRatingsFiles(localizationPath))
+ {
+ LoadRatings(file);
+ }
+ }
+
+ private List<string> GetRatingsFiles(string directory)
+ {
+ return _fileSystem.GetFilePaths(directory, false)
+ .Where(i => string.Equals(Path.GetExtension(i), ".txt", StringComparison.OrdinalIgnoreCase))
+ .Where(i => Path.GetFileName(i).StartsWith("ratings-", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ /// <summary>
+ /// Gets the localization path.
+ /// </summary>
+ /// <value>The localization path.</value>
+ public string LocalizationPath
+ {
+ get
+ {
+ return Path.Combine(_configurationManager.ApplicationPaths.ProgramDataPath, "localization");
+ }
+ }
+
+ public string RemoveDiacritics(string text)
+ {
+ return _textLocalizer.RemoveDiacritics(text);
+ }
+
+ public string NormalizeFormKD(string text)
+ {
+ return _textLocalizer.NormalizeFormKD(text);
+ }
+
+ /// <summary>
+ /// Gets the cultures.
+ /// </summary>
+ /// <returns>IEnumerable{CultureDto}.</returns>
+ public IEnumerable<CultureDto> GetCultures()
+ {
+ var type = GetType();
+ var path = type.Namespace + ".iso6392.txt";
+
+ var list = new List<CultureDto>();
+
+ using (var stream = _assemblyInfo.GetManifestResourceStream(type, path))
+ {
+ using (var reader = new StreamReader(stream))
+ {
+ while (!reader.EndOfStream)
+ {
+ var line = reader.ReadLine();
+
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ var parts = line.Split('|');
+
+ if (parts.Length == 5)
+ {
+ list.Add(new CultureDto
+ {
+ DisplayName = parts[3],
+ Name = parts[3],
+ ThreeLetterISOLanguageName = parts[0],
+ TwoLetterISOLanguageName = parts[2]
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return list.Where(i => !string.IsNullOrWhiteSpace(i.Name) &&
+ !string.IsNullOrWhiteSpace(i.DisplayName) &&
+ !string.IsNullOrWhiteSpace(i.ThreeLetterISOLanguageName) &&
+ !string.IsNullOrWhiteSpace(i.TwoLetterISOLanguageName));
+ }
+
+ /// <summary>
+ /// Gets the countries.
+ /// </summary>
+ /// <returns>IEnumerable{CountryInfo}.</returns>
+ public IEnumerable<CountryInfo> GetCountries()
+ {
+ var type = GetType();
+ var path = type.Namespace + ".countries.json";
+
+ using (var stream = _assemblyInfo.GetManifestResourceStream(type, path))
+ {
+ return _jsonSerializer.DeserializeFromStream<List<CountryInfo>>(stream);
+ }
+ }
+
+ /// <summary>
+ /// Gets the parental ratings.
+ /// </summary>
+ /// <returns>IEnumerable{ParentalRating}.</returns>
+ public IEnumerable<ParentalRating> GetParentalRatings()
+ {
+ return GetParentalRatingsDictionary().Values.ToList();
+ }
+
+ /// <summary>
+ /// Gets the parental ratings dictionary.
+ /// </summary>
+ /// <returns>Dictionary{System.StringParentalRating}.</returns>
+ private Dictionary<string, ParentalRating> GetParentalRatingsDictionary()
+ {
+ var countryCode = _configurationManager.Configuration.MetadataCountryCode;
+
+ if (string.IsNullOrEmpty(countryCode))
+ {
+ countryCode = "us";
+ }
+
+ var ratings = GetRatings(countryCode);
+
+ if (ratings == null)
+ {
+ ratings = GetRatings("us");
+ }
+
+ return ratings;
+ }
+
+ /// <summary>
+ /// Gets the ratings.
+ /// </summary>
+ /// <param name="countryCode">The country code.</param>
+ private Dictionary<string, ParentalRating> GetRatings(string countryCode)
+ {
+ Dictionary<string, ParentalRating> value;
+
+ _allParentalRatings.TryGetValue(countryCode, out value);
+
+ return value;
+ }
+
+ /// <summary>
+ /// Loads the ratings.
+ /// </summary>
+ /// <param name="file">The file.</param>
+ /// <returns>Dictionary{System.StringParentalRating}.</returns>
+ private void LoadRatings(string file)
+ {
+ var dict = _fileSystem.ReadAllLines(file).Select(i =>
+ {
+ if (!string.IsNullOrWhiteSpace(i))
+ {
+ var parts = i.Split(',');
+
+ if (parts.Length == 2)
+ {
+ int value;
+
+ if (int.TryParse(parts[1], NumberStyles.Integer, UsCulture, out value))
+ {
+ return new ParentalRating { Name = parts[0], Value = value };
+ }
+ }
+ }
+
+ return null;
+
+ })
+ .Where(i => i != null)
+ .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase);
+
+ var countryCode = _fileSystem.GetFileNameWithoutExtension(file)
+ .Split('-')
+ .Last();
+
+ _allParentalRatings.TryAdd(countryCode, dict);
+ }
+
+ private readonly string[] _unratedValues = {"n/a", "unrated", "not rated"};
+
+ /// <summary>
+ /// Gets the rating level.
+ /// </summary>
+ public int? GetRatingLevel(string rating)
+ {
+ if (string.IsNullOrEmpty(rating))
+ {
+ throw new ArgumentNullException("rating");
+ }
+
+ if (_unratedValues.Contains(rating, StringComparer.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ // Fairly common for some users to have "Rated R" in their rating field
+ rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase);
+
+ var ratingsDictionary = GetParentalRatingsDictionary();
+
+ ParentalRating value;
+
+ if (!ratingsDictionary.TryGetValue(rating, out value))
+ {
+ // If we don't find anything check all ratings systems
+ foreach (var dictionary in _allParentalRatings.Values)
+ {
+ if (dictionary.TryGetValue(rating, out value))
+ {
+ return value.Value;
+ }
+ }
+ }
+
+ return value == null ? (int?)null : value.Value;
+ }
+
+ public string GetLocalizedString(string phrase)
+ {
+ return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
+ }
+
+ public string GetLocalizedString(string phrase, string culture)
+ {
+ var dictionary = GetLocalizationDictionary(culture);
+
+ string value;
+
+ if (dictionary.TryGetValue(phrase, out value))
+ {
+ return value;
+ }
+
+ return phrase;
+ }
+
+ private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries =
+ new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
+
+ public Dictionary<string, string> GetLocalizationDictionary(string culture)
+ {
+ const string prefix = "Core";
+ var key = prefix + culture;
+
+ return _dictionaries.GetOrAdd(key, k => GetDictionary(prefix, culture, "core.json"));
+ }
+
+ private Dictionary<string, string> GetDictionary(string prefix, string culture, string baseFilename)
+ {
+ var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ var namespaceName = GetType().Namespace + "." + prefix;
+
+ CopyInto(dictionary, namespaceName + "." + baseFilename);
+ CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture));
+
+ return dictionary;
+ }
+
+ private void CopyInto(IDictionary<string, string> dictionary, string resourcePath)
+ {
+ using (var stream = _assemblyInfo.GetManifestResourceStream(GetType(), resourcePath))
+ {
+ if (stream != null)
+ {
+ var dict = _jsonSerializer.DeserializeFromStream<Dictionary<string, string>>(stream);
+
+ foreach (var key in dict.Keys)
+ {
+ dictionary[key] = dict[key];
+ }
+ }
+ }
+ }
+
+ private string GetResourceFilename(string culture)
+ {
+ var parts = culture.Split('-');
+
+ if (parts.Length == 2)
+ {
+ culture = parts[0].ToLower() + "-" + parts[1].ToUpper();
+ }
+ else
+ {
+ culture = culture.ToLower();
+ }
+
+ return culture + ".json";
+ }
+
+ public IEnumerable<LocalizatonOption> GetLocalizationOptions()
+ {
+ return new List<LocalizatonOption>
+ {
+ new LocalizatonOption{ Name="Arabic", Value="ar"},
+ new LocalizatonOption{ Name="Bulgarian (Bulgaria)", Value="bg-BG"},
+ new LocalizatonOption{ Name="Catalan", Value="ca"},
+ new LocalizatonOption{ Name="Chinese Simplified", Value="zh-CN"},
+ new LocalizatonOption{ Name="Chinese Traditional", Value="zh-TW"},
+ new LocalizatonOption{ Name="Croatian", Value="hr"},
+ new LocalizatonOption{ Name="Czech", Value="cs"},
+ new LocalizatonOption{ Name="Danish", Value="da"},
+ new LocalizatonOption{ Name="Dutch", Value="nl"},
+ new LocalizatonOption{ Name="English (United Kingdom)", Value="en-GB"},
+ new LocalizatonOption{ Name="English (United States)", Value="en-us"},
+ new LocalizatonOption{ Name="Finnish", Value="fi"},
+ new LocalizatonOption{ Name="French", Value="fr"},
+ new LocalizatonOption{ Name="French (Canada)", Value="fr-CA"},
+ new LocalizatonOption{ Name="German", Value="de"},
+ new LocalizatonOption{ Name="Greek", Value="el"},
+ new LocalizatonOption{ Name="Hebrew", Value="he"},
+ new LocalizatonOption{ Name="Hungarian", Value="hu"},
+ new LocalizatonOption{ Name="Indonesian", Value="id"},
+ new LocalizatonOption{ Name="Italian", Value="it"},
+ new LocalizatonOption{ Name="Kazakh", Value="kk"},
+ new LocalizatonOption{ Name="Norwegian Bokmål", Value="nb"},
+ new LocalizatonOption{ Name="Polish", Value="pl"},
+ new LocalizatonOption{ Name="Portuguese (Brazil)", Value="pt-BR"},
+ new LocalizatonOption{ Name="Portuguese (Portugal)", Value="pt-PT"},
+ new LocalizatonOption{ Name="Russian", Value="ru"},
+ new LocalizatonOption{ Name="Slovenian (Slovenia)", Value="sl-SI"},
+ new LocalizatonOption{ Name="Spanish", Value="es-ES"},
+ new LocalizatonOption{ Name="Spanish (Mexico)", Value="es-MX"},
+ new LocalizatonOption{ Name="Swedish", Value="sv"},
+ new LocalizatonOption{ Name="Turkish", Value="tr"},
+ new LocalizatonOption{ Name="Ukrainian", Value="uk"},
+ new LocalizatonOption{ Name="Vietnamese", Value="vi"}
+
+ }.OrderBy(i => i.Name);
+ }
+ }
+
+ public interface ITextLocalizer
+ {
+ string RemoveDiacritics(string text);
+
+ string NormalizeFormKD(string text);
+ }
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/au.txt b/Emby.Server.Implementations/Localization/Ratings/au.txt
new file mode 100644
index 000000000..fa60f5305
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/au.txt
@@ -0,0 +1,8 @@
+AU-G,1
+AU-PG,5
+AU-M,6
+AU-MA15+,7
+AU-M15+,8
+AU-R18+,9
+AU-X18+,10
+AU-RC,11
diff --git a/Emby.Server.Implementations/Localization/Ratings/be.txt b/Emby.Server.Implementations/Localization/Ratings/be.txt
new file mode 100644
index 000000000..99a53f664
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/be.txt
@@ -0,0 +1,6 @@
+BE-AL,1
+BE-MG6,2
+BE-6,3
+BE-9,5
+BE-12,6
+BE-16,8 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/br.txt b/Emby.Server.Implementations/Localization/Ratings/br.txt
new file mode 100644
index 000000000..62f00fb87
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/br.txt
@@ -0,0 +1,6 @@
+BR-L,1
+BR-10,5
+BR-12,7
+BR-14,8
+BR-16,8
+BR-18,9 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.txt b/Emby.Server.Implementations/Localization/Ratings/ca.txt
new file mode 100644
index 000000000..5a110648c
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ca.txt
@@ -0,0 +1,6 @@
+CA-G,1
+CA-PG,5
+CA-14A,7
+CA-A,8
+CA-18A,9
+CA-R,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/co.txt b/Emby.Server.Implementations/Localization/Ratings/co.txt
new file mode 100644
index 000000000..a694a0be6
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/co.txt
@@ -0,0 +1,8 @@
+CO-T,1
+CO-7,5
+CO-12,7
+CO-15,8
+CO-18,10
+CO-X,100
+CO-BANNED,15
+CO-E,15 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/de.txt b/Emby.Server.Implementations/Localization/Ratings/de.txt
new file mode 100644
index 000000000..ad1f18619
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/de.txt
@@ -0,0 +1,10 @@
+DE-0,1
+FSK-0,1
+DE-6,5
+FSK-6,5
+DE-12,7
+FSK-12,7
+DE-16,8
+FSK-16,8
+DE-18,9
+FSK-18,9 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/dk.txt b/Emby.Server.Implementations/Localization/Ratings/dk.txt
new file mode 100644
index 000000000..b9a085e01
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/dk.txt
@@ -0,0 +1,4 @@
+DA-A,1
+DA-7,5
+DA-11,6
+DA-15,8 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.txt b/Emby.Server.Implementations/Localization/Ratings/fr.txt
new file mode 100644
index 000000000..2bb205b0d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/fr.txt
@@ -0,0 +1,5 @@
+FR-U,1
+FR-10,5
+FR-12,7
+FR-16,9
+FR-18,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/gb.txt b/Emby.Server.Implementations/Localization/Ratings/gb.txt
new file mode 100644
index 000000000..c1f7d0452
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/gb.txt
@@ -0,0 +1,7 @@
+GB-U,1
+GB-PG,5
+GB-12,6
+GB-12A,7
+GB-15,8
+GB-18,9
+GB-R18,15
diff --git a/Emby.Server.Implementations/Localization/Ratings/ie.txt b/Emby.Server.Implementations/Localization/Ratings/ie.txt
new file mode 100644
index 000000000..283f07767
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ie.txt
@@ -0,0 +1,6 @@
+IE-G,1
+IE-PG,5
+IE-12A,7
+IE-15A,8
+IE-16,9
+IE-18,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/jp.txt b/Emby.Server.Implementations/Localization/Ratings/jp.txt
new file mode 100644
index 000000000..2e1da30d8
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/jp.txt
@@ -0,0 +1,4 @@
+JP-G,1
+JP-PG12,7
+JP-15+,8
+JP-18+,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/kz.txt b/Emby.Server.Implementations/Localization/Ratings/kz.txt
new file mode 100644
index 000000000..b31e12d96
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/kz.txt
@@ -0,0 +1,6 @@
+KZ-К,1
+KZ-БА,6
+KZ-Б14,7
+KZ-Е16,8
+KZ-Е18,10
+KZ-НА,15 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/mx.txt b/Emby.Server.Implementations/Localization/Ratings/mx.txt
new file mode 100644
index 000000000..93b609c3d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/mx.txt
@@ -0,0 +1,6 @@
+MX-AA,1
+MX-A,5
+MX-B,7
+MX-B-15,8
+MX-C,9
+MX-D,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/nl.txt b/Emby.Server.Implementations/Localization/Ratings/nl.txt
new file mode 100644
index 000000000..f69cc2bcc
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/nl.txt
@@ -0,0 +1,6 @@
+NL-AL,1
+NL-MG6,2
+NL-6,3
+NL-9,5
+NL-12,6
+NL-16,8 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/nz.txt b/Emby.Server.Implementations/Localization/Ratings/nz.txt
new file mode 100644
index 000000000..bc761dcab
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/nz.txt
@@ -0,0 +1,10 @@
+NZ-G,1
+NZ-PG,5
+NZ-M,9
+NZ-R13,7
+NZ-R15,8
+NZ-R16,9
+NZ-R18,10
+NZ-RP13,7
+NZ-RP16,9
+NZ-R,10 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/Ratings/ru.txt b/Emby.Server.Implementations/Localization/Ratings/ru.txt
new file mode 100644
index 000000000..1bc94affd
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ru.txt
@@ -0,0 +1,5 @@
+RU-0+,1
+RU-6+,3
+RU-12+,7
+RU-16+,9
+RU-18+,10
diff --git a/Emby.Server.Implementations/Localization/Ratings/us.txt b/Emby.Server.Implementations/Localization/Ratings/us.txt
new file mode 100644
index 000000000..3f5311e0e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/us.txt
@@ -0,0 +1,22 @@
+G,1
+E,1
+EC,1
+TV-G,1
+TV-Y,2
+TV-Y7,3
+TV-Y7-FV,4
+PG,5
+TV-PG,5
+PG-13,7
+T,7
+TV-14,8
+R,9
+M,9
+TV-MA,9
+NC-17,10
+AO,15
+RP,15
+UR,15
+NR,15
+X,15
+XXX,100 \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json
new file mode 100644
index 000000000..e671b3685
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/countries.json
@@ -0,0 +1 @@
+[{"Name":"AF","DisplayName":"Afghanistan","TwoLetterISORegionName":"AF","ThreeLetterISORegionName":"AFG"},{"Name":"AL","DisplayName":"Albania","TwoLetterISORegionName":"AL","ThreeLetterISORegionName":"ALB"},{"Name":"DZ","DisplayName":"Algeria","TwoLetterISORegionName":"DZ","ThreeLetterISORegionName":"DZA"},{"Name":"AR","DisplayName":"Argentina","TwoLetterISORegionName":"AR","ThreeLetterISORegionName":"ARG"},{"Name":"AM","DisplayName":"Armenia","TwoLetterISORegionName":"AM","ThreeLetterISORegionName":"ARM"},{"Name":"AU","DisplayName":"Australia","TwoLetterISORegionName":"AU","ThreeLetterISORegionName":"AUS"},{"Name":"AT","DisplayName":"Austria","TwoLetterISORegionName":"AT","ThreeLetterISORegionName":"AUT"},{"Name":"AZ","DisplayName":"Azerbaijan","TwoLetterISORegionName":"AZ","ThreeLetterISORegionName":"AZE"},{"Name":"BH","DisplayName":"Bahrain","TwoLetterISORegionName":"BH","ThreeLetterISORegionName":"BHR"},{"Name":"BD","DisplayName":"Bangladesh","TwoLetterISORegionName":"BD","ThreeLetterISORegionName":"BGD"},{"Name":"BY","DisplayName":"Belarus","TwoLetterISORegionName":"BY","ThreeLetterISORegionName":"BLR"},{"Name":"BE","DisplayName":"Belgium","TwoLetterISORegionName":"BE","ThreeLetterISORegionName":"BEL"},{"Name":"BZ","DisplayName":"Belize","TwoLetterISORegionName":"BZ","ThreeLetterISORegionName":"BLZ"},{"Name":"VE","DisplayName":"Bolivarian Republic of Venezuela","TwoLetterISORegionName":"VE","ThreeLetterISORegionName":"VEN"},{"Name":"BO","DisplayName":"Bolivia","TwoLetterISORegionName":"BO","ThreeLetterISORegionName":"BOL"},{"Name":"BA","DisplayName":"Bosnia and Herzegovina","TwoLetterISORegionName":"BA","ThreeLetterISORegionName":"BIH"},{"Name":"BW","DisplayName":"Botswana","TwoLetterISORegionName":"BW","ThreeLetterISORegionName":"BWA"},{"Name":"BR","DisplayName":"Brazil","TwoLetterISORegionName":"BR","ThreeLetterISORegionName":"BRA"},{"Name":"BN","DisplayName":"Brunei Darussalam","TwoLetterISORegionName":"BN","ThreeLetterISORegionName":"BRN"},{"Name":"BG","DisplayName":"Bulgaria","TwoLetterISORegionName":"BG","ThreeLetterISORegionName":"BGR"},{"Name":"KH","DisplayName":"Cambodia","TwoLetterISORegionName":"KH","ThreeLetterISORegionName":"KHM"},{"Name":"CM","DisplayName":"Cameroon","TwoLetterISORegionName":"CM","ThreeLetterISORegionName":"CMR"},{"Name":"CA","DisplayName":"Canada","TwoLetterISORegionName":"CA","ThreeLetterISORegionName":"CAN"},{"Name":"029","DisplayName":"Caribbean","TwoLetterISORegionName":"029","ThreeLetterISORegionName":"029"},{"Name":"CL","DisplayName":"Chile","TwoLetterISORegionName":"CL","ThreeLetterISORegionName":"CHL"},{"Name":"CO","DisplayName":"Colombia","TwoLetterISORegionName":"CO","ThreeLetterISORegionName":"COL"},{"Name":"CD","DisplayName":"Congo [DRC]","TwoLetterISORegionName":"CD","ThreeLetterISORegionName":"COD"},{"Name":"CR","DisplayName":"Costa Rica","TwoLetterISORegionName":"CR","ThreeLetterISORegionName":"CRI"},{"Name":"HR","DisplayName":"Croatia","TwoLetterISORegionName":"HR","ThreeLetterISORegionName":"HRV"},{"Name":"CZ","DisplayName":"Czech Republic","TwoLetterISORegionName":"CZ","ThreeLetterISORegionName":"CZE"},{"Name":"DK","DisplayName":"Denmark","TwoLetterISORegionName":"DK","ThreeLetterISORegionName":"DNK"},{"Name":"DO","DisplayName":"Dominican Republic","TwoLetterISORegionName":"DO","ThreeLetterISORegionName":"DOM"},{"Name":"EC","DisplayName":"Ecuador","TwoLetterISORegionName":"EC","ThreeLetterISORegionName":"ECU"},{"Name":"EG","DisplayName":"Egypt","TwoLetterISORegionName":"EG","ThreeLetterISORegionName":"EGY"},{"Name":"SV","DisplayName":"El Salvador","TwoLetterISORegionName":"SV","ThreeLetterISORegionName":"SLV"},{"Name":"ER","DisplayName":"Eritrea","TwoLetterISORegionName":"ER","ThreeLetterISORegionName":"ERI"},{"Name":"EE","DisplayName":"Estonia","TwoLetterISORegionName":"EE","ThreeLetterISORegionName":"EST"},{"Name":"ET","DisplayName":"Ethiopia","TwoLetterISORegionName":"ET","ThreeLetterISORegionName":"ETH"},{"Name":"FO","DisplayName":"Faroe Islands","TwoLetterISORegionName":"FO","ThreeLetterISORegionName":"FRO"},{"Name":"FI","DisplayName":"Finland","TwoLetterISORegionName":"FI","ThreeLetterISORegionName":"FIN"},{"Name":"FR","DisplayName":"France","TwoLetterISORegionName":"FR","ThreeLetterISORegionName":"FRA"},{"Name":"GE","DisplayName":"Georgia","TwoLetterISORegionName":"GE","ThreeLetterISORegionName":"GEO"},{"Name":"DE","DisplayName":"Germany","TwoLetterISORegionName":"DE","ThreeLetterISORegionName":"DEU"},{"Name":"GR","DisplayName":"Greece","TwoLetterISORegionName":"GR","ThreeLetterISORegionName":"GRC"},{"Name":"GL","DisplayName":"Greenland","TwoLetterISORegionName":"GL","ThreeLetterISORegionName":"GRL"},{"Name":"GT","DisplayName":"Guatemala","TwoLetterISORegionName":"GT","ThreeLetterISORegionName":"GTM"},{"Name":"HT","DisplayName":"Haiti","TwoLetterISORegionName":"HT","ThreeLetterISORegionName":"HTI"},{"Name":"HN","DisplayName":"Honduras","TwoLetterISORegionName":"HN","ThreeLetterISORegionName":"HND"},{"Name":"HK","DisplayName":"Hong Kong S.A.R.","TwoLetterISORegionName":"HK","ThreeLetterISORegionName":"HKG"},{"Name":"HU","DisplayName":"Hungary","TwoLetterISORegionName":"HU","ThreeLetterISORegionName":"HUN"},{"Name":"IS","DisplayName":"Iceland","TwoLetterISORegionName":"IS","ThreeLetterISORegionName":"ISL"},{"Name":"IN","DisplayName":"India","TwoLetterISORegionName":"IN","ThreeLetterISORegionName":"IND"},{"Name":"ID","DisplayName":"Indonesia","TwoLetterISORegionName":"ID","ThreeLetterISORegionName":"IDN"},{"Name":"IR","DisplayName":"Iran","TwoLetterISORegionName":"IR","ThreeLetterISORegionName":"IRN"},{"Name":"IQ","DisplayName":"Iraq","TwoLetterISORegionName":"IQ","ThreeLetterISORegionName":"IRQ"},{"Name":"IE","DisplayName":"Ireland","TwoLetterISORegionName":"IE","ThreeLetterISORegionName":"IRL"},{"Name":"PK","DisplayName":"Islamic Republic of Pakistan","TwoLetterISORegionName":"PK","ThreeLetterISORegionName":"PAK"},{"Name":"IL","DisplayName":"Israel","TwoLetterISORegionName":"IL","ThreeLetterISORegionName":"ISR"},{"Name":"IT","DisplayName":"Italy","TwoLetterISORegionName":"IT","ThreeLetterISORegionName":"ITA"},{"Name":"CI","DisplayName":"Ivory Coast","TwoLetterISORegionName":"CI","ThreeLetterISORegionName":"CIV"},{"Name":"JM","DisplayName":"Jamaica","TwoLetterISORegionName":"JM","ThreeLetterISORegionName":"JAM"},{"Name":"JP","DisplayName":"Japan","TwoLetterISORegionName":"JP","ThreeLetterISORegionName":"JPN"},{"Name":"JO","DisplayName":"Jordan","TwoLetterISORegionName":"JO","ThreeLetterISORegionName":"JOR"},{"Name":"KZ","DisplayName":"Kazakhstan","TwoLetterISORegionName":"KZ","ThreeLetterISORegionName":"KAZ"},{"Name":"KE","DisplayName":"Kenya","TwoLetterISORegionName":"KE","ThreeLetterISORegionName":"KEN"},{"Name":"KR","DisplayName":"Korea","TwoLetterISORegionName":"KR","ThreeLetterISORegionName":"KOR"},{"Name":"KW","DisplayName":"Kuwait","TwoLetterISORegionName":"KW","ThreeLetterISORegionName":"KWT"},{"Name":"KG","DisplayName":"Kyrgyzstan","TwoLetterISORegionName":"KG","ThreeLetterISORegionName":"KGZ"},{"Name":"LA","DisplayName":"Lao P.D.R.","TwoLetterISORegionName":"LA","ThreeLetterISORegionName":"LAO"},{"Name":"419","DisplayName":"Latin America","TwoLetterISORegionName":"419","ThreeLetterISORegionName":"419"},{"Name":"LV","DisplayName":"Latvia","TwoLetterISORegionName":"LV","ThreeLetterISORegionName":"LVA"},{"Name":"LB","DisplayName":"Lebanon","TwoLetterISORegionName":"LB","ThreeLetterISORegionName":"LBN"},{"Name":"LY","DisplayName":"Libya","TwoLetterISORegionName":"LY","ThreeLetterISORegionName":"LBY"},{"Name":"LI","DisplayName":"Liechtenstein","TwoLetterISORegionName":"LI","ThreeLetterISORegionName":"LIE"},{"Name":"LT","DisplayName":"Lithuania","TwoLetterISORegionName":"LT","ThreeLetterISORegionName":"LTU"},{"Name":"LU","DisplayName":"Luxembourg","TwoLetterISORegionName":"LU","ThreeLetterISORegionName":"LUX"},{"Name":"MO","DisplayName":"Macao S.A.R.","TwoLetterISORegionName":"MO","ThreeLetterISORegionName":"MAC"},{"Name":"MK","DisplayName":"Macedonia (FYROM)","TwoLetterISORegionName":"MK","ThreeLetterISORegionName":"MKD"},{"Name":"MY","DisplayName":"Malaysia","TwoLetterISORegionName":"MY","ThreeLetterISORegionName":"MYS"},{"Name":"MV","DisplayName":"Maldives","TwoLetterISORegionName":"MV","ThreeLetterISORegionName":"MDV"},{"Name":"ML","DisplayName":"Mali","TwoLetterISORegionName":"ML","ThreeLetterISORegionName":"MLI"},{"Name":"MT","DisplayName":"Malta","TwoLetterISORegionName":"MT","ThreeLetterISORegionName":"MLT"},{"Name":"MX","DisplayName":"Mexico","TwoLetterISORegionName":"MX","ThreeLetterISORegionName":"MEX"},{"Name":"MN","DisplayName":"Mongolia","TwoLetterISORegionName":"MN","ThreeLetterISORegionName":"MNG"},{"Name":"ME","DisplayName":"Montenegro","TwoLetterISORegionName":"ME","ThreeLetterISORegionName":"MNE"},{"Name":"MA","DisplayName":"Morocco","TwoLetterISORegionName":"MA","ThreeLetterISORegionName":"MAR"},{"Name":"NP","DisplayName":"Nepal","TwoLetterISORegionName":"NP","ThreeLetterISORegionName":"NPL"},{"Name":"NL","DisplayName":"Netherlands","TwoLetterISORegionName":"NL","ThreeLetterISORegionName":"NLD"},{"Name":"NZ","DisplayName":"New Zealand","TwoLetterISORegionName":"NZ","ThreeLetterISORegionName":"NZL"},{"Name":"NI","DisplayName":"Nicaragua","TwoLetterISORegionName":"NI","ThreeLetterISORegionName":"NIC"},{"Name":"NG","DisplayName":"Nigeria","TwoLetterISORegionName":"NG","ThreeLetterISORegionName":"NGA"},{"Name":"NO","DisplayName":"Norway","TwoLetterISORegionName":"NO","ThreeLetterISORegionName":"NOR"},{"Name":"OM","DisplayName":"Oman","TwoLetterISORegionName":"OM","ThreeLetterISORegionName":"OMN"},{"Name":"PA","DisplayName":"Panama","TwoLetterISORegionName":"PA","ThreeLetterISORegionName":"PAN"},{"Name":"PY","DisplayName":"Paraguay","TwoLetterISORegionName":"PY","ThreeLetterISORegionName":"PRY"},{"Name":"CN","DisplayName":"People's Republic of China","TwoLetterISORegionName":"CN","ThreeLetterISORegionName":"CHN"},{"Name":"PE","DisplayName":"Peru","TwoLetterISORegionName":"PE","ThreeLetterISORegionName":"PER"},{"Name":"PH","DisplayName":"Philippines","TwoLetterISORegionName":"PH","ThreeLetterISORegionName":"PHL"},{"Name":"PL","DisplayName":"Poland","TwoLetterISORegionName":"PL","ThreeLetterISORegionName":"POL"},{"Name":"PT","DisplayName":"Portugal","TwoLetterISORegionName":"PT","ThreeLetterISORegionName":"PRT"},{"Name":"MC","DisplayName":"Principality of Monaco","TwoLetterISORegionName":"MC","ThreeLetterISORegionName":"MCO"},{"Name":"PR","DisplayName":"Puerto Rico","TwoLetterISORegionName":"PR","ThreeLetterISORegionName":"PRI"},{"Name":"QA","DisplayName":"Qatar","TwoLetterISORegionName":"QA","ThreeLetterISORegionName":"QAT"},{"Name":"MD","DisplayName":"Republica Moldova","TwoLetterISORegionName":"MD","ThreeLetterISORegionName":"MDA"},{"Name":"RE","DisplayName":"Réunion","TwoLetterISORegionName":"RE","ThreeLetterISORegionName":"REU"},{"Name":"RO","DisplayName":"Romania","TwoLetterISORegionName":"RO","ThreeLetterISORegionName":"ROU"},{"Name":"RU","DisplayName":"Russia","TwoLetterISORegionName":"RU","ThreeLetterISORegionName":"RUS"},{"Name":"RW","DisplayName":"Rwanda","TwoLetterISORegionName":"RW","ThreeLetterISORegionName":"RWA"},{"Name":"SA","DisplayName":"Saudi Arabia","TwoLetterISORegionName":"SA","ThreeLetterISORegionName":"SAU"},{"Name":"SN","DisplayName":"Senegal","TwoLetterISORegionName":"SN","ThreeLetterISORegionName":"SEN"},{"Name":"RS","DisplayName":"Serbia","TwoLetterISORegionName":"RS","ThreeLetterISORegionName":"SRB"},{"Name":"CS","DisplayName":"Serbia and Montenegro (Former)","TwoLetterISORegionName":"CS","ThreeLetterISORegionName":"SCG"},{"Name":"SG","DisplayName":"Singapore","TwoLetterISORegionName":"SG","ThreeLetterISORegionName":"SGP"},{"Name":"SK","DisplayName":"Slovakia","TwoLetterISORegionName":"SK","ThreeLetterISORegionName":"SVK"},{"Name":"SI","DisplayName":"Slovenia","TwoLetterISORegionName":"SI","ThreeLetterISORegionName":"SVN"},{"Name":"SO","DisplayName":"Soomaaliya","TwoLetterISORegionName":"SO","ThreeLetterISORegionName":"SOM"},{"Name":"ZA","DisplayName":"South Africa","TwoLetterISORegionName":"ZA","ThreeLetterISORegionName":"ZAF"},{"Name":"ES","DisplayName":"Spain","TwoLetterISORegionName":"ES","ThreeLetterISORegionName":"ESP"},{"Name":"LK","DisplayName":"Sri Lanka","TwoLetterISORegionName":"LK","ThreeLetterISORegionName":"LKA"},{"Name":"SE","DisplayName":"Sweden","TwoLetterISORegionName":"SE","ThreeLetterISORegionName":"SWE"},{"Name":"CH","DisplayName":"Switzerland","TwoLetterISORegionName":"CH","ThreeLetterISORegionName":"CHE"},{"Name":"SY","DisplayName":"Syria","TwoLetterISORegionName":"SY","ThreeLetterISORegionName":"SYR"},{"Name":"TW","DisplayName":"Taiwan","TwoLetterISORegionName":"TW","ThreeLetterISORegionName":"TWN"},{"Name":"TJ","DisplayName":"Tajikistan","TwoLetterISORegionName":"TJ","ThreeLetterISORegionName":"TAJ"},{"Name":"TH","DisplayName":"Thailand","TwoLetterISORegionName":"TH","ThreeLetterISORegionName":"THA"},{"Name":"TT","DisplayName":"Trinidad and Tobago","TwoLetterISORegionName":"TT","ThreeLetterISORegionName":"TTO"},{"Name":"TN","DisplayName":"Tunisia","TwoLetterISORegionName":"TN","ThreeLetterISORegionName":"TUN"},{"Name":"TR","DisplayName":"Turkey","TwoLetterISORegionName":"TR","ThreeLetterISORegionName":"TUR"},{"Name":"TM","DisplayName":"Turkmenistan","TwoLetterISORegionName":"TM","ThreeLetterISORegionName":"TKM"},{"Name":"AE","DisplayName":"U.A.E.","TwoLetterISORegionName":"AE","ThreeLetterISORegionName":"ARE"},{"Name":"UA","DisplayName":"Ukraine","TwoLetterISORegionName":"UA","ThreeLetterISORegionName":"UKR"},{"Name":"GB","DisplayName":"United Kingdom","TwoLetterISORegionName":"GB","ThreeLetterISORegionName":"GBR"},{"Name":"US","DisplayName":"United States","TwoLetterISORegionName":"US","ThreeLetterISORegionName":"USA"},{"Name":"UY","DisplayName":"Uruguay","TwoLetterISORegionName":"UY","ThreeLetterISORegionName":"URY"},{"Name":"UZ","DisplayName":"Uzbekistan","TwoLetterISORegionName":"UZ","ThreeLetterISORegionName":"UZB"},{"Name":"VN","DisplayName":"Vietnam","TwoLetterISORegionName":"VN","ThreeLetterISORegionName":"VNM"},{"Name":"YE","DisplayName":"Yemen","TwoLetterISORegionName":"YE","ThreeLetterISORegionName":"YEM"},{"Name":"ZW","DisplayName":"Zimbabwe","TwoLetterISORegionName":"ZW","ThreeLetterISORegionName":"ZWE"}] \ No newline at end of file
diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt
new file mode 100644
index 000000000..665a5375e
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/iso6392.txt
@@ -0,0 +1,487 @@
+aar||aa|Afar|afar
+abk||ab|Abkhazian|abkhaze
+ace|||Achinese|aceh
+ach|||Acoli|acoli
+ada|||Adangme|adangme
+ady|||Adyghe; Adygei|adyghé
+afa|||Afro-Asiatic languages|afro-asiatiques, langues
+afh|||Afrihili|afrihili
+afr||af|Afrikaans|afrikaans
+ain|||Ainu|aïnou
+aka||ak|Akan|akan
+akk|||Akkadian|akkadien
+alb|sqi|sq|Albanian|albanais
+ale|||Aleut|aléoute
+alg|||Algonquian languages|algonquines, langues
+alt|||Southern Altai|altai du Sud
+amh||am|Amharic|amharique
+ang|||English, Old (ca.450-1100)|anglo-saxon (ca.450-1100)
+anp|||Angika|angika
+apa|||Apache languages|apaches, langues
+ara||ar|Arabic|arabe
+arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE)
+arg||an|Aragonese|aragonais
+arm|hye|hy|Armenian|arménien
+arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce
+arp|||Arapaho|arapaho
+art|||Artificial languages|artificielles, langues
+arw|||Arawak|arawak
+asm||as|Assamese|assamais
+ast|||Asturian; Bable; Leonese; Asturleonese|asturien; bable; léonais; asturoléonais
+ath|||Athapascan languages|athapascanes, langues
+aus|||Australian languages|australiennes, langues
+ava||av|Avaric|avar
+ave||ae|Avestan|avestique
+awa|||Awadhi|awadhi
+aym||ay|Aymara|aymara
+aze||az|Azerbaijani|azéri
+bad|||Banda languages|banda, langues
+bai|||Bamileke languages|bamiléké, langues
+bak||ba|Bashkir|bachkir
+bal|||Baluchi|baloutchi
+bam||bm|Bambara|bambara
+ban|||Balinese|balinais
+baq|eus|eu|Basque|basque
+bas|||Basa|basa
+bat|||Baltic languages|baltes, langues
+bej|||Beja; Bedawiyet|bedja
+bel||be|Belarusian|biélorusse
+bem|||Bemba|bemba
+ben||bn|Bengali|bengali
+ber|||Berber languages|berbères, langues
+bho|||Bhojpuri|bhojpuri
+bih||bh|Bihari languages|langues biharis
+bik|||Bikol|bikol
+bin|||Bini; Edo|bini; edo
+bis||bi|Bislama|bichlamar
+bla|||Siksika|blackfoot
+bnt|||Bantu (Other)|bantoues, autres langues
+bos||bs|Bosnian|bosniaque
+bra|||Braj|braj
+bre||br|Breton|breton
+btk|||Batak languages|batak, langues
+bua|||Buriat|bouriate
+bug|||Buginese|bugi
+bul||bg|Bulgarian|bulgare
+bur|mya|my|Burmese|birman
+byn|||Blin; Bilin|blin; bilen
+cad|||Caddo|caddo
+cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues
+car|||Galibi Carib|karib; galibi; carib
+cat||ca|Catalan; Valencian|catalan; valencien
+cau|||Caucasian languages|caucasiennes, langues
+ceb|||Cebuano|cebuano
+cel|||Celtic languages|celtiques, langues; celtes, langues
+cha||ch|Chamorro|chamorro
+chb|||Chibcha|chibcha
+che||ce|Chechen|tchétchène
+chg|||Chagatai|djaghataï
+chi|zho|zh|Chinese|chinois
+chk|||Chuukese|chuuk
+chm|||Mari|mari
+chn|||Chinook jargon|chinook, jargon
+cho|||Choctaw|choctaw
+chp|||Chipewyan; Dene Suline|chipewyan
+chr|||Cherokee|cherokee
+chu||cu|Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic|slavon d'église; vieux slave; slavon liturgique; vieux bulgare
+chv||cv|Chuvash|tchouvache
+chy|||Cheyenne|cheyenne
+cmc|||Chamic languages|chames, langues
+cop|||Coptic|copte
+cor||kw|Cornish|cornique
+cos||co|Corsican|corse
+cpe|||Creoles and pidgins, English based|créoles et pidgins basés sur l'anglais
+cpf|||Creoles and pidgins, French-based |créoles et pidgins basés sur le français
+cpp|||Creoles and pidgins, Portuguese-based |créoles et pidgins basés sur le portugais
+cre||cr|Cree|cree
+crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé
+crp|||Creoles and pidgins |créoles et pidgins
+csb|||Kashubian|kachoube
+cus|||Cushitic languages|couchitiques, langues
+cze|ces|cs|Czech|tchèque
+dak|||Dakota|dakota
+dan||da|Danish|danois
+dar|||Dargwa|dargwa
+day|||Land Dayak languages|dayak, langues
+del|||Delaware|delaware
+den|||Slave (Athapascan)|esclave (athapascan)
+dgr|||Dogrib|dogrib
+din|||Dinka|dinka
+div||dv|Divehi; Dhivehi; Maldivian|maldivien
+doi|||Dogri|dogri
+dra|||Dravidian languages|dravidiennes, langues
+dsb|||Lower Sorbian|bas-sorabe
+dua|||Duala|douala
+dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350)
+dut|nld|nl|Dutch; Flemish|néerlandais; flamand
+dyu|||Dyula|dioula
+dzo||dz|Dzongkha|dzongkha
+efi|||Efik|efik
+egy|||Egyptian (Ancient)|égyptien
+eka|||Ekajuk|ekajuk
+elx|||Elamite|élamite
+eng||en|English|anglais
+enm|||English, Middle (1100-1500)|anglais moyen (1100-1500)
+epo||eo|Esperanto|espéranto
+est||et|Estonian|estonien
+ewe||ee|Ewe|éwé
+ewo|||Ewondo|éwondo
+fan|||Fang|fang
+fao||fo|Faroese|féroïen
+fat|||Fanti|fanti
+fij||fj|Fijian|fidjien
+fil|||Filipino; Pilipino|filipino; pilipino
+fin||fi|Finnish|finnois
+fiu|||Finno-Ugrian languages|finno-ougriennes, langues
+fon|||Fon|fon
+fre|fra|fr|French|français
+frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600)
+fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400)
+frr|||Northern Frisian|frison septentrional
+frs|||Eastern Frisian|frison oriental
+fry||fy|Western Frisian|frison occidental
+ful||ff|Fulah|peul
+fur|||Friulian|frioulan
+gaa|||Ga|ga
+gay|||Gayo|gayo
+gba|||Gbaya|gbaya
+gem|||Germanic languages|germaniques, langues
+geo|kat|ka|Georgian|géorgien
+ger|deu|de|German|allemand
+gez|||Geez|guèze
+gil|||Gilbertese|kiribati
+gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais
+gle||ga|Irish|irlandais
+glg||gl|Galician|galicien
+glv||gv|Manx|manx; mannois
+gmh|||German, Middle High (ca.1050-1500)|allemand, moyen haut (ca. 1050-1500)
+goh|||German, Old High (ca.750-1050)|allemand, vieux haut (ca. 750-1050)
+gon|||Gondi|gond
+gor|||Gorontalo|gorontalo
+got|||Gothic|gothique
+grb|||Grebo|grebo
+grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453)
+gre|ell|el|Greek, Modern (1453-)|grec moderne (après 1453)
+grn||gn|Guarani|guarani
+gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien
+guj||gu|Gujarati|goudjrati
+gwi|||Gwich'in|gwich'in
+hai|||Haida|haida
+hat||ht|Haitian; Haitian Creole|haïtien; créole haïtien
+hau||ha|Hausa|haoussa
+haw|||Hawaiian|hawaïen
+heb||he|Hebrew|hébreu
+her||hz|Herero|herero
+hil|||Hiligaynon|hiligaynon
+him|||Himachali languages; Western Pahari languages|langues himachalis; langues paharis occidentales
+hin||hi|Hindi|hindi
+hit|||Hittite|hittite
+hmn|||Hmong; Mong|hmong
+hmo||ho|Hiri Motu|hiri motu
+hrv||hr|Croatian|croate
+hsb|||Upper Sorbian|haut-sorabe
+hun||hu|Hungarian|hongrois
+hup|||Hupa|hupa
+iba|||Iban|iban
+ibo||ig|Igbo|igbo
+ice|isl|is|Icelandic|islandais
+ido||io|Ido|ido
+iii||ii|Sichuan Yi; Nuosu|yi de Sichuan
+ijo|||Ijo languages|ijo, langues
+iku||iu|Inuktitut|inuktitut
+ile||ie|Interlingue; Occidental|interlingue
+ilo|||Iloko|ilocano
+ina||ia|Interlingua (International Auxiliary Language Association)|interlingua (langue auxiliaire internationale)
+inc|||Indic languages|indo-aryennes, langues
+ind||id|Indonesian|indonésien
+ine|||Indo-European languages|indo-européennes, langues
+inh|||Ingush|ingouche
+ipk||ik|Inupiaq|inupiaq
+ira|||Iranian languages|iraniennes, langues
+iro|||Iroquoian languages|iroquoises, langues
+ita||it|Italian|italien
+jav||jv|Javanese|javanais
+jbo|||Lojban|lojban
+jpn||ja|Japanese|japonais
+jpr|||Judeo-Persian|judéo-persan
+jrb|||Judeo-Arabic|judéo-arabe
+kaa|||Kara-Kalpak|karakalpak
+kab|||Kabyle|kabyle
+kac|||Kachin; Jingpho|kachin; jingpho
+kal||kl|Kalaallisut; Greenlandic|groenlandais
+kam|||Kamba|kamba
+kan||kn|Kannada|kannada
+kar|||Karen languages|karen, langues
+kas||ks|Kashmiri|kashmiri
+kau||kr|Kanuri|kanouri
+kaw|||Kawi|kawi
+kaz||kk|Kazakh|kazakh
+kbd|||Kabardian|kabardien
+kha|||Khasi|khasi
+khi|||Khoisan languages|khoïsan, langues
+khm||km|Central Khmer|khmer central
+kho|||Khotanese; Sakan|khotanais; sakan
+kik||ki|Kikuyu; Gikuyu|kikuyu
+kin||rw|Kinyarwanda|rwanda
+kir||ky|Kirghiz; Kyrgyz|kirghiz
+kmb|||Kimbundu|kimbundu
+kok|||Konkani|konkani
+kom||kv|Komi|kom
+kon||kg|Kongo|kongo
+kor||ko|Korean|coréen
+kos|||Kosraean|kosrae
+kpe|||Kpelle|kpellé
+krc|||Karachay-Balkar|karatchai balkar
+krl|||Karelian|carélien
+kro|||Kru languages|krou, langues
+kru|||Kurukh|kurukh
+kua||kj|Kuanyama; Kwanyama|kuanyama; kwanyama
+kum|||Kumyk|koumyk
+kur||ku|Kurdish|kurde
+kut|||Kutenai|kutenai
+lad|||Ladino|judéo-espagnol
+lah|||Lahnda|lahnda
+lam|||Lamba|lamba
+lao||lo|Lao|lao
+lat||la|Latin|latin
+lav||lv|Latvian|letton
+lez|||Lezghian|lezghien
+lim||li|Limburgan; Limburger; Limburgish|limbourgeois
+lin||ln|Lingala|lingala
+lit||lt|Lithuanian|lituanien
+lol|||Mongo|mongo
+loz|||Lozi|lozi
+ltz||lb|Luxembourgish; Letzeburgesch|luxembourgeois
+lua|||Luba-Lulua|luba-lulua
+lub||lu|Luba-Katanga|luba-katanga
+lug||lg|Ganda|ganda
+lui|||Luiseno|luiseno
+lun|||Lunda|lunda
+luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie)
+lus|||Lushai|lushai
+mac|mkd|mk|Macedonian|macédonien
+mad|||Madurese|madourais
+mag|||Magahi|magahi
+mah||mh|Marshallese|marshall
+mai|||Maithili|maithili
+mak|||Makasar|makassar
+mal||ml|Malayalam|malayalam
+man|||Mandingo|mandingue
+mao|mri|mi|Maori|maori
+map|||Austronesian languages|austronésiennes, langues
+mar||mr|Marathi|marathe
+mas|||Masai|massaï
+may|msa|ms|Malay|malais
+mdf|||Moksha|moksa
+mdr|||Mandar|mandar
+men|||Mende|mendé
+mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200)
+mic|||Mi'kmaq; Micmac|mi'kmaq; micmac
+min|||Minangkabau|minangkabau
+mis|||Uncoded languages|langues non codées
+mkh|||Mon-Khmer languages|môn-khmer, langues
+mlg||mg|Malagasy|malgache
+mlt||mt|Maltese|maltais
+mnc|||Manchu|mandchou
+mni|||Manipuri|manipuri
+mno|||Manobo languages|manobo, langues
+moh|||Mohawk|mohawk
+mon||mn|Mongolian|mongol
+mos|||Mossi|moré
+mul|||Multiple languages|multilingue
+mun|||Munda languages|mounda, langues
+mus|||Creek|muskogee
+mwl|||Mirandese|mirandais
+mwr|||Marwari|marvari
+myn|||Mayan languages|maya, langues
+myv|||Erzya|erza
+nah|||Nahuatl languages|nahuatl, langues
+nai|||North American Indian languages|nord-amérindiennes, langues
+nap|||Neapolitan|napolitain
+nau||na|Nauru|nauruan
+nav||nv|Navajo; Navaho|navaho
+nbl||nr|Ndebele, South; South Ndebele|ndébélé du Sud
+nde||nd|Ndebele, North; North Ndebele|ndébélé du Nord
+ndo||ng|Ndonga|ndonga
+nds|||Low German; Low Saxon; German, Low; Saxon, Low|bas allemand; bas saxon; allemand, bas; saxon, bas
+nep||ne|Nepali|népalais
+new|||Nepal Bhasa; Newari|nepal bhasa; newari
+nia|||Nias|nias
+nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
+niu|||Niuean|niué
+nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien
+nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål
+nog|||Nogai|nogaï; nogay
+non|||Norse, Old|norrois, vieux
+nor||no|Norwegian|norvégien
+nqo|||N'Ko|n'ko
+nso|||Pedi; Sepedi; Northern Sotho|pedi; sepedi; sotho du Nord
+nub|||Nubian languages|nubiennes, langues
+nwc|||Classical Newari; Old Newari; Classical Nepal Bhasa|newari classique
+nya||ny|Chichewa; Chewa; Nyanja|chichewa; chewa; nyanja
+nym|||Nyamwezi|nyamwezi
+nyn|||Nyankole|nyankolé
+nyo|||Nyoro|nyoro
+nzi|||Nzima|nzema
+oci||oc|Occitan (post 1500); Provençal|occitan (après 1500); provençal
+oji||oj|Ojibwa|ojibwa
+ori||or|Oriya|oriya
+orm||om|Oromo|galla
+osa|||Osage|osage
+oss||os|Ossetian; Ossetic|ossète
+ota|||Turkish, Ottoman (1500-1928)|turc ottoman (1500-1928)
+oto|||Otomian languages|otomi, langues
+paa|||Papuan languages|papoues, langues
+pag|||Pangasinan|pangasinan
+pal|||Pahlavi|pahlavi
+pam|||Pampanga; Kapampangan|pampangan
+pan||pa|Panjabi; Punjabi|pendjabi
+pap|||Papiamento|papiamento
+pau|||Palauan|palau
+peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.)
+per|fas|fa|Persian|persan
+phi|||Philippine languages|philippines, langues
+phn|||Phoenician|phénicien
+pli||pi|Pali|pali
+pol||pl|Polish|polonais
+pon|||Pohnpeian|pohnpei
+por||pt|Portuguese|portugais
+pob||pt-br|Portuguese (Brazil)|portugais
+pra|||Prakrit languages|prâkrit, langues
+pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
+pus||ps|Pushto; Pashto|pachto
+qaa-qtz|||Reserved for local use|réservée à l'usage local
+que||qu|Quechua|quechua
+raj|||Rajasthani|rajasthani
+rap|||Rapanui|rapanui
+rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook
+roa|||Romance languages|romanes, langues
+roh||rm|Romansh|romanche
+rom|||Romany|tsigane
+rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave
+run||rn|Rundi|rundi
+rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain
+rus||ru|Russian|russe
+sad|||Sandawe|sandawe
+sag||sg|Sango|sango
+sah|||Yakut|iakoute
+sai|||South American Indian (Other)|indiennes d'Amérique du Sud, autres langues
+sal|||Salishan languages|salishennes, langues
+sam|||Samaritan Aramaic|samaritain
+san||sa|Sanskrit|sanskrit
+sas|||Sasak|sasak
+sat|||Santali|santal
+scn|||Sicilian|sicilien
+sco|||Scots|écossais
+sel|||Selkup|selkoupe
+sem|||Semitic languages|sémitiques, langues
+sga|||Irish, Old (to 900)|irlandais ancien (jusqu'à 900)
+sgn|||Sign Languages|langues des signes
+shn|||Shan|chan
+sid|||Sidamo|sidamo
+sin||si|Sinhala; Sinhalese|singhalais
+sio|||Siouan languages|sioux, langues
+sit|||Sino-Tibetan languages|sino-tibétaines, langues
+sla|||Slavic languages|slaves, langues
+slo|slk|sk|Slovak|slovaque
+slv||sl|Slovenian|slovène
+sma|||Southern Sami|sami du Sud
+sme||se|Northern Sami|sami du Nord
+smi|||Sami languages|sames, langues
+smj|||Lule Sami|sami de Lule
+smn|||Inari Sami|sami d'Inari
+smo||sm|Samoan|samoan
+sms|||Skolt Sami|sami skolt
+sna||sn|Shona|shona
+snd||sd|Sindhi|sindhi
+snk|||Soninke|soninké
+sog|||Sogdian|sogdien
+som||so|Somali|somali
+son|||Songhai languages|songhai, langues
+sot||st|Sotho, Southern|sotho du Sud
+spa||es|Spanish; Castilian|espagnol; castillan
+srd||sc|Sardinian|sarde
+srn|||Sranan Tongo|sranan tongo
+srp||sr|Serbian|serbe
+srr|||Serer|sérère
+ssa|||Nilo-Saharan languages|nilo-sahariennes, langues
+ssw||ss|Swati|swati
+suk|||Sukuma|sukuma
+sun||su|Sundanese|soundanais
+sus|||Susu|soussou
+sux|||Sumerian|sumérien
+swa||sw|Swahili|swahili
+swe||sv|Swedish|suédois
+syc|||Classical Syriac|syriaque classique
+syr|||Syriac|syriaque
+tah||ty|Tahitian|tahitien
+tai|||Tai languages|tai, langues
+tam||ta|Tamil|tamoul
+tat||tt|Tatar|tatar
+tel||te|Telugu|télougou
+tem|||Timne|temne
+ter|||Tereno|tereno
+tet|||Tetum|tetum
+tgk||tg|Tajik|tadjik
+tgl||tl|Tagalog|tagalog
+tha||th|Thai|thaï
+tib|bod|bo|Tibetan|tibétain
+tig|||Tigre|tigré
+tir||ti|Tigrinya|tigrigna
+tiv|||Tiv|tiv
+tkl|||Tokelau|tokelau
+tlh|||Klingon; tlhIngan-Hol|klingon
+tli|||Tlingit|tlingit
+tmh|||Tamashek|tamacheq
+tog|||Tonga (Nyasa)|tonga (Nyasa)
+ton||to|Tonga (Tonga Islands)|tongan (Îles Tonga)
+tpi|||Tok Pisin|tok pisin
+tsi|||Tsimshian|tsimshian
+tsn||tn|Tswana|tswana
+tso||ts|Tsonga|tsonga
+tuk||tk|Turkmen|turkmène
+tum|||Tumbuka|tumbuka
+tup|||Tupi languages|tupi, langues
+tur||tr|Turkish|turc
+tut|||Altaic languages|altaïques, langues
+tvl|||Tuvalu|tuvalu
+twi||tw|Twi|twi
+tyv|||Tuvinian|touva
+udm|||Udmurt|oudmourte
+uga|||Ugaritic|ougaritique
+uig||ug|Uighur; Uyghur|ouïgour
+ukr||uk|Ukrainian|ukrainien
+umb|||Umbundu|umbundu
+und|||Undetermined|indéterminée
+urd||ur|Urdu|ourdou
+uzb||uz|Uzbek|ouszbek
+vai|||Vai|vaï
+ven||ve|Venda|venda
+vie||vi|Vietnamese|vietnamien
+vol||vo|Volapük|volapük
+vot|||Votic|vote
+wak|||Wakashan languages|wakashanes, langues
+wal|||Walamo|walamo
+war|||Waray|waray
+was|||Washo|washo
+wel|cym|cy|Welsh|gallois
+wen|||Sorbian languages|sorabes, langues
+wln||wa|Walloon|wallon
+wol||wo|Wolof|wolof
+xal|||Kalmyk; Oirat|kalmouk; oïrat
+xho||xh|Xhosa|xhosa
+yao|||Yao|yao
+yap|||Yapese|yapois
+yid||yi|Yiddish|yiddish
+yor||yo|Yoruba|yoruba
+ypk|||Yupik languages|yupik, langues
+zap|||Zapotec|zapotèque
+zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss
+zen|||Zenaga|zenaga
+zgh|||Standard Moroccan Tamazight|amazighe standard marocain
+zha||za|Zhuang; Chuang|zhuang; chuang
+znd|||Zande languages|zandé, langues
+zul||zu|Zulu|zoulou
+zun|||Zuni|zuni
+zxx|||No linguistic content; Not applicable|pas de contenu linguistique; non applicable
+zza|||Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki|zaza; dimili; dimli; kirdki; kirmanjki; zazaki \ No newline at end of file
diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
new file mode 100644
index 000000000..204e04061
--- /dev/null
+++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
@@ -0,0 +1,236 @@
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Library;
+
+namespace Emby.Server.Implementations.MediaEncoder
+{
+ public class EncodingManager : IEncodingManager
+ {
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+ private readonly IMediaEncoder _encoder;
+ private readonly IChapterManager _chapterManager;
+ private readonly ILibraryManager _libraryManager;
+
+ public EncodingManager(IFileSystem fileSystem,
+ ILogger logger,
+ IMediaEncoder encoder,
+ IChapterManager chapterManager, ILibraryManager libraryManager)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _encoder = encoder;
+ _chapterManager = chapterManager;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Gets the chapter images data path.
+ /// </summary>
+ /// <value>The chapter images data path.</value>
+ private string GetChapterImagesPath(IHasImages item)
+ {
+ return Path.Combine(item.GetInternalMetadataPath(), "chapters");
+ }
+
+ /// <summary>
+ /// Determines whether [is eligible for chapter image extraction] [the specified video].
+ /// </summary>
+ /// <param name="video">The video.</param>
+ /// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c>.</returns>
+ private bool IsEligibleForChapterImageExtraction(Video video)
+ {
+ if (video.IsPlaceHolder)
+ {
+ return false;
+ }
+
+ var libraryOptions = _libraryManager.GetLibraryOptions(video);
+ if (libraryOptions != null)
+ {
+ if (!libraryOptions.EnableChapterImageExtraction)
+ {
+ return false;
+ }
+ }
+ else
+ {
+ return false;
+ }
+
+ // Can't extract images if there are no video streams
+ return video.DefaultVideoStreamIndex.HasValue;
+ }
+
+ /// <summary>
+ /// The first chapter ticks
+ /// </summary>
+ private static readonly long FirstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
+
+ public async Task<bool> RefreshChapterImages(ChapterImageRefreshOptions options, CancellationToken cancellationToken)
+ {
+ var extractImages = options.ExtractImages;
+ var video = options.Video;
+ var chapters = options.Chapters;
+ var saveChapters = options.SaveChapters;
+
+ if (!IsEligibleForChapterImageExtraction(video))
+ {
+ extractImages = false;
+ }
+
+ var success = true;
+ var changesMade = false;
+
+ var runtimeTicks = video.RunTimeTicks ?? 0;
+
+ var currentImages = GetSavedChapterImages(video);
+
+ foreach (var chapter in chapters)
+ {
+ if (chapter.StartPositionTicks >= runtimeTicks)
+ {
+ _logger.Info("Stopping chapter extraction for {0} because a chapter was found with a position greater than the runtime.", video.Name);
+ break;
+ }
+
+ var path = GetChapterImagePath(video, chapter.StartPositionTicks);
+
+ if (!currentImages.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ if (extractImages)
+ {
+ if (video.VideoType == VideoType.HdDvd || video.VideoType == VideoType.Iso)
+ {
+ continue;
+ }
+ if (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)
+ {
+ if (video.PlayableStreamFileNames.Count != 1)
+ {
+ continue;
+ }
+ }
+
+ try
+ {
+ // Add some time for the first chapter to make sure we don't end up with a black image
+ var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(FirstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
+
+ var protocol = MediaProtocol.File;
+
+ var inputPath = MediaEncoderHelpers.GetInputArgument(_fileSystem, video.Path, protocol, null, video.PlayableStreamFileNames);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ var container = video.Container;
+
+ var tempFile = await _encoder.ExtractVideoImage(inputPath, container, protocol, video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
+ _fileSystem.CopyFile(tempFile, path, true);
+
+ try
+ {
+ _fileSystem.DeleteFile(tempFile);
+ }
+ catch
+ {
+
+ }
+
+ chapter.ImagePath = path;
+ chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
+ changesMade = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error extracting chapter images for {0}", ex, string.Join(",", video.Path));
+ success = false;
+ break;
+ }
+ }
+ else if (!string.IsNullOrEmpty(chapter.ImagePath))
+ {
+ chapter.ImagePath = null;
+ changesMade = true;
+ }
+ }
+ else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
+ {
+ chapter.ImagePath = path;
+ chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
+ changesMade = true;
+ }
+ }
+
+ if (saveChapters && changesMade)
+ {
+ await _chapterManager.SaveChapters(video.Id.ToString(), chapters, cancellationToken).ConfigureAwait(false);
+ }
+
+ DeleteDeadImages(currentImages, chapters);
+
+ return success;
+ }
+
+ private string GetChapterImagePath(Video video, long chapterPositionTicks)
+ {
+ var filename = video.DateModified.Ticks.ToString(_usCulture) + "_" + chapterPositionTicks.ToString(_usCulture) + ".jpg";
+
+ return Path.Combine(GetChapterImagesPath(video), filename);
+ }
+
+ private List<string> GetSavedChapterImages(Video video)
+ {
+ var path = GetChapterImagesPath(video);
+
+ try
+ {
+ return _fileSystem.GetFilePaths(path)
+ .ToList();
+ }
+ catch (IOException)
+ {
+ return new List<string>();
+ }
+ }
+
+ private void DeleteDeadImages(IEnumerable<string> images, IEnumerable<ChapterInfo> chapters)
+ {
+ var deadImages = images
+ .Except(chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i)), StringComparer.OrdinalIgnoreCase)
+ .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i), StringComparer.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var image in deadImages)
+ {
+ _logger.Debug("Deleting dead chapter image {0}", image);
+
+ try
+ {
+ _fileSystem.DeleteFile(image);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error deleting {0}.", ex, image);
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Migrations/IVersionMigration.cs b/Emby.Server.Implementations/Migrations/IVersionMigration.cs
new file mode 100644
index 000000000..7804912e3
--- /dev/null
+++ b/Emby.Server.Implementations/Migrations/IVersionMigration.cs
@@ -0,0 +1,9 @@
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Migrations
+{
+ public interface IVersionMigration
+ {
+ Task Run();
+ }
+}
diff --git a/Emby.Server.Implementations/Migrations/LibraryScanMigration.cs b/Emby.Server.Implementations/Migrations/LibraryScanMigration.cs
new file mode 100644
index 000000000..c494abc0b
--- /dev/null
+++ b/Emby.Server.Implementations/Migrations/LibraryScanMigration.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Updates;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Migrations
+{
+ public class LibraryScanMigration : IVersionMigration
+ {
+ private readonly IServerConfigurationManager _config;
+ private readonly ITaskManager _taskManager;
+
+ public LibraryScanMigration(IServerConfigurationManager config, ITaskManager taskManager)
+ {
+ _config = config;
+ _taskManager = taskManager;
+ }
+
+ public async Task Run()
+ {
+ var name = "LibraryScan6";
+
+ if (!_config.Configuration.Migrations.Contains(name, StringComparer.OrdinalIgnoreCase))
+ {
+ Task.Run(() =>
+ {
+ var task = _taskManager.ScheduledTasks.Select(i => i.ScheduledTask)
+ .First(i => string.Equals(i.Key, "RefreshLibrary", StringComparison.OrdinalIgnoreCase));
+
+ _taskManager.QueueScheduledTask(task);
+ });
+
+ var list = _config.Configuration.Migrations.ToList();
+ list.Add(name);
+ _config.Configuration.Migrations = list.ToArray();
+ _config.SaveConfiguration();
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Migrations/UpdateLevelMigration.cs b/Emby.Server.Implementations/Migrations/UpdateLevelMigration.cs
new file mode 100644
index 000000000..c532ea08d
--- /dev/null
+++ b/Emby.Server.Implementations/Migrations/UpdateLevelMigration.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Updates;
+
+namespace Emby.Server.Implementations.Migrations
+{
+ public class UpdateLevelMigration : IVersionMigration
+ {
+ private readonly IServerConfigurationManager _config;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly string _releaseAssetFilename;
+ private readonly ILogger _logger;
+
+ public UpdateLevelMigration(IServerConfigurationManager config, IServerApplicationHost appHost, IHttpClient httpClient, IJsonSerializer jsonSerializer, string releaseAssetFilename, ILogger logger)
+ {
+ _config = config;
+ _appHost = appHost;
+ _httpClient = httpClient;
+ _jsonSerializer = jsonSerializer;
+ _releaseAssetFilename = releaseAssetFilename;
+ _logger = logger;
+ }
+
+ public async Task Run()
+ {
+ var lastVersion = _config.Configuration.LastVersion;
+ var currentVersion = _appHost.ApplicationVersion;
+
+ if (string.Equals(lastVersion, currentVersion.ToString(), StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ try
+ {
+ var updateLevel = _config.Configuration.SystemUpdateLevel;
+
+ await CheckVersion(currentVersion, updateLevel, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in update migration", ex);
+ }
+ }
+
+ private async Task CheckVersion(Version currentVersion, PackageVersionClass currentUpdateLevel, CancellationToken cancellationToken)
+ {
+ var releases = await new GithubUpdater(_httpClient, _jsonSerializer)
+ .GetLatestReleases("MediaBrowser", "Emby", _releaseAssetFilename, cancellationToken).ConfigureAwait(false);
+
+ var newUpdateLevel = GetNewUpdateLevel(currentVersion, currentUpdateLevel, releases);
+
+ if (newUpdateLevel != currentUpdateLevel)
+ {
+ _config.Configuration.SystemUpdateLevel = newUpdateLevel;
+ _config.SaveConfiguration();
+ }
+ }
+
+ private PackageVersionClass GetNewUpdateLevel(Version currentVersion, PackageVersionClass currentUpdateLevel, List<GithubUpdater.RootObject> releases)
+ {
+ var newUpdateLevel = currentUpdateLevel;
+
+ // If the current version is later than current stable, set the update level to beta
+ if (releases.Count >= 1)
+ {
+ var release = releases[0];
+ var version = ParseVersion(release.tag_name);
+ if (version != null)
+ {
+ if (currentVersion > version)
+ {
+ newUpdateLevel = PackageVersionClass.Beta;
+ }
+ else
+ {
+ return PackageVersionClass.Release;
+ }
+ }
+ }
+
+ // If the current version is later than current beta, set the update level to dev
+ if (releases.Count >= 2)
+ {
+ var release = releases[1];
+ var version = ParseVersion(release.tag_name);
+ if (version != null)
+ {
+ if (currentVersion > version)
+ {
+ newUpdateLevel = PackageVersionClass.Dev;
+ }
+ else
+ {
+ return PackageVersionClass.Beta;
+ }
+ }
+ }
+
+ return newUpdateLevel;
+ }
+
+ private Version ParseVersion(string versionString)
+ {
+ if (!string.IsNullOrWhiteSpace(versionString))
+ {
+ var parts = versionString.Split('.');
+ if (parts.Length == 3)
+ {
+ versionString += ".0";
+ }
+ }
+
+ Version version;
+ Version.TryParse(versionString, out version);
+
+ return version;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/News/NewsEntryPoint.cs b/Emby.Server.Implementations/News/NewsEntryPoint.cs
new file mode 100644
index 000000000..53c862d47
--- /dev/null
+++ b/Emby.Server.Implementations/News/NewsEntryPoint.cs
@@ -0,0 +1,275 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.News;
+using MediaBrowser.Model.Notifications;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.News
+{
+ public class NewsEntryPoint : IServerEntryPoint
+ {
+ private ITimer _timer;
+ private readonly IHttpClient _httpClient;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+ private readonly IJsonSerializer _json;
+
+ private readonly INotificationManager _notifications;
+ private readonly IUserManager _userManager;
+
+ private readonly TimeSpan _frequency = TimeSpan.FromHours(24);
+ private readonly ITimerFactory _timerFactory;
+
+ public NewsEntryPoint(IHttpClient httpClient, IApplicationPaths appPaths, IFileSystem fileSystem, ILogger logger, IJsonSerializer json, INotificationManager notifications, IUserManager userManager, ITimerFactory timerFactory)
+ {
+ _httpClient = httpClient;
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _json = json;
+ _notifications = notifications;
+ _userManager = userManager;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ _timer = _timerFactory.Create(OnTimerFired, null, TimeSpan.FromMilliseconds(500), _frequency);
+ }
+
+ /// <summary>
+ /// Called when [timer fired].
+ /// </summary>
+ /// <param name="state">The state.</param>
+ private async void OnTimerFired(object state)
+ {
+ var path = Path.Combine(_appPaths.CachePath, "news.json");
+
+ try
+ {
+ await DownloadNews(path).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error downloading news", ex);
+ }
+ }
+
+ private async Task DownloadNews(string path)
+ {
+ DateTime? lastUpdate = null;
+
+ if (_fileSystem.FileExists(path))
+ {
+ lastUpdate = _fileSystem.GetLastWriteTimeUtc(path);
+ }
+
+ var requestOptions = new HttpRequestOptions
+ {
+ Url = "http://emby.media/community/index.php?/blog/rss/1-media-browser-developers-blog",
+ Progress = new Progress<double>(),
+ UserAgent = "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.42 Safari/537.36",
+ BufferContent = false
+ };
+
+ using (var stream = await _httpClient.Get(requestOptions).ConfigureAwait(false))
+ {
+ using (var reader = XmlReader.Create(stream))
+ {
+ var news = ParseRssItems(reader).ToList();
+
+ _json.SerializeToFile(news, path);
+
+ await CreateNotifications(news, lastUpdate, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private Task CreateNotifications(List<NewsItem> items, DateTime? lastUpdate, CancellationToken cancellationToken)
+ {
+ if (lastUpdate.HasValue)
+ {
+ items = items.Where(i => i.Date.ToUniversalTime() >= lastUpdate.Value)
+ .ToList();
+ }
+
+ var tasks = items.Select(i => _notifications.SendNotification(new NotificationRequest
+ {
+ Date = i.Date,
+ Name = i.Title,
+ Description = i.Description,
+ Url = i.Link,
+ UserIds = _userManager.Users.Select(u => u.Id.ToString("N")).ToList()
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ private IEnumerable<NewsItem> ParseRssItems(XmlReader reader)
+ {
+ reader.MoveToContent();
+ reader.Read();
+
+ while (!reader.EOF && reader.ReadState == ReadState.Interactive)
+ {
+ if (reader.NodeType == XmlNodeType.Element)
+ {
+ switch (reader.Name)
+ {
+ case "channel":
+ {
+ if (!reader.IsEmptyElement)
+ {
+ using (var subReader = reader.ReadSubtree())
+ {
+ return ParseFromChannelNode(subReader);
+ }
+ }
+ else
+ {
+ reader.Read();
+ }
+ break;
+ }
+ default:
+ {
+ reader.Skip();
+ break;
+ }
+ }
+ }
+ else
+ {
+ reader.Read();
+ }
+ }
+
+ return new List<NewsItem>();
+ }
+
+ private IEnumerable<NewsItem> ParseFromChannelNode(XmlReader reader)
+ {
+ var list = new List<NewsItem>();
+
+ reader.MoveToContent();
+ reader.Read();
+
+ while (!reader.EOF && reader.ReadState == ReadState.Interactive)
+ {
+ if (reader.NodeType == XmlNodeType.Element)
+ {
+ switch (reader.Name)
+ {
+ case "item":
+ {
+ if (!reader.IsEmptyElement)
+ {
+ using (var subReader = reader.ReadSubtree())
+ {
+ list.Add(ParseItem(subReader));
+ }
+ }
+ else
+ {
+ reader.Read();
+ }
+ break;
+ }
+ default:
+ {
+ reader.Skip();
+ break;
+ }
+ }
+ }
+ else
+ {
+ reader.Read();
+ }
+ }
+
+ return list;
+ }
+
+ private NewsItem ParseItem(XmlReader reader)
+ {
+ var item = new NewsItem();
+
+ reader.MoveToContent();
+ reader.Read();
+
+ while (!reader.EOF && reader.ReadState == ReadState.Interactive)
+ {
+ if (reader.NodeType == XmlNodeType.Element)
+ {
+ switch (reader.Name)
+ {
+ case "title":
+ {
+ item.Title = reader.ReadElementContentAsString();
+ break;
+ }
+ case "link":
+ {
+ item.Link = reader.ReadElementContentAsString();
+ break;
+ }
+ case "description":
+ {
+ item.DescriptionHtml = reader.ReadElementContentAsString();
+ item.Description = item.DescriptionHtml.StripHtml();
+ break;
+ }
+ case "pubDate":
+ {
+ var date = reader.ReadElementContentAsString();
+ DateTime parsedDate;
+
+ if (DateTime.TryParse(date, out parsedDate))
+ {
+ item.Date = parsedDate;
+ }
+ break;
+ }
+ default:
+ {
+ reader.Skip();
+ break;
+ }
+ }
+ }
+ else
+ {
+ reader.Read();
+ }
+ }
+
+ return item;
+ }
+
+ public void Dispose()
+ {
+ if (_timer != null)
+ {
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/News/NewsService.cs b/Emby.Server.Implementations/News/NewsService.cs
new file mode 100644
index 000000000..80e799634
--- /dev/null
+++ b/Emby.Server.Implementations/News/NewsService.cs
@@ -0,0 +1,77 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.News;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Emby.Server.Implementations.News
+{
+ public class NewsService : INewsService
+ {
+ private readonly IApplicationPaths _appPaths;
+ private readonly IJsonSerializer _json;
+
+ public NewsService(IApplicationPaths appPaths, IJsonSerializer json)
+ {
+ _appPaths = appPaths;
+ _json = json;
+ }
+
+ public QueryResult<NewsItem> GetProductNews(NewsQuery query)
+ {
+ try
+ {
+ return GetProductNewsInternal(query);
+ }
+ catch (FileNotFoundException)
+ {
+ // No biggie
+ return new QueryResult<NewsItem>
+ {
+ Items = new NewsItem[] { }
+ };
+ }
+ catch (IOException)
+ {
+ // No biggie
+ return new QueryResult<NewsItem>
+ {
+ Items = new NewsItem[] { }
+ };
+ }
+ }
+
+ private QueryResult<NewsItem> GetProductNewsInternal(NewsQuery query)
+ {
+ var path = Path.Combine(_appPaths.CachePath, "news.json");
+
+ var items = GetNewsItems(path).OrderByDescending(i => i.Date);
+
+ var itemsArray = items.ToArray();
+ var count = itemsArray.Length;
+
+ if (query.StartIndex.HasValue)
+ {
+ itemsArray = itemsArray.Skip(query.StartIndex.Value).ToArray();
+ }
+
+ if (query.Limit.HasValue)
+ {
+ itemsArray = itemsArray.Take(query.Limit.Value).ToArray();
+ }
+
+ return new QueryResult<NewsItem>
+ {
+ Items = itemsArray,
+ TotalRecordCount = count
+ };
+ }
+
+ private IEnumerable<NewsItem> GetNewsItems(string path)
+ {
+ return _json.DeserializeFromFile<List<NewsItem>>(path);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/CoreNotificationTypes.cs b/Emby.Server.Implementations/Notifications/CoreNotificationTypes.cs
new file mode 100644
index 000000000..f9fb98f85
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/CoreNotificationTypes.cs
@@ -0,0 +1,198 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Notifications;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.Globalization;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ public class CoreNotificationTypes : INotificationTypeFactory
+ {
+ private readonly ILocalizationManager _localization;
+ private readonly IServerApplicationHost _appHost;
+
+ public CoreNotificationTypes(ILocalizationManager localization, IServerApplicationHost appHost)
+ {
+ _localization = localization;
+ _appHost = appHost;
+ }
+
+ public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
+ {
+ var knownTypes = new List<NotificationTypeInfo>
+ {
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.ApplicationUpdateInstalled.ToString(),
+ DefaultDescription = "{ReleaseNotes}",
+ DefaultTitle = "A new version of Emby Server has been installed.",
+ Variables = new List<string>{"Version"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.InstallationFailed.ToString(),
+ DefaultTitle = "{Name} installation failed.",
+ Variables = new List<string>{"Name", "Version"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.PluginInstalled.ToString(),
+ DefaultTitle = "{Name} was installed.",
+ Variables = new List<string>{"Name", "Version"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.PluginError.ToString(),
+ DefaultTitle = "{Name} has encountered an error.",
+ DefaultDescription = "{ErrorMessage}",
+ Variables = new List<string>{"Name", "ErrorMessage"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.PluginUninstalled.ToString(),
+ DefaultTitle = "{Name} was uninstalled.",
+ Variables = new List<string>{"Name", "Version"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.PluginUpdateInstalled.ToString(),
+ DefaultTitle = "{Name} was updated.",
+ DefaultDescription = "{ReleaseNotes}",
+ Variables = new List<string>{"Name", "ReleaseNotes", "Version"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.ServerRestartRequired.ToString(),
+ DefaultTitle = "Please restart Emby Server to finish updating."
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.TaskFailed.ToString(),
+ DefaultTitle = "{Name} failed.",
+ DefaultDescription = "{ErrorMessage}",
+ Variables = new List<string>{"Name", "ErrorMessage"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.NewLibraryContent.ToString(),
+ DefaultTitle = "{Name} has been added to your media library.",
+ Variables = new List<string>{"Name"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.AudioPlayback.ToString(),
+ DefaultTitle = "{UserName} is playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.GamePlayback.ToString(),
+ DefaultTitle = "{UserName} is playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.VideoPlayback.ToString(),
+ DefaultTitle = "{UserName} is playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.AudioPlaybackStopped.ToString(),
+ DefaultTitle = "{UserName} has finished playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.GamePlaybackStopped.ToString(),
+ DefaultTitle = "{UserName} has finished playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.VideoPlaybackStopped.ToString(),
+ DefaultTitle = "{UserName} has finished playing {ItemName} on {DeviceName}.",
+ Variables = new List<string>{"UserName", "ItemName", "DeviceName", "AppName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.CameraImageUploaded.ToString(),
+ DefaultTitle = "A new camera image has been uploaded from {DeviceName}.",
+ Variables = new List<string>{"DeviceName"}
+ },
+
+ new NotificationTypeInfo
+ {
+ Type = NotificationType.UserLockedOut.ToString(),
+ DefaultTitle = "{UserName} has been locked out.",
+ Variables = new List<string>{"UserName"}
+ }
+ };
+
+ if (!_appHost.CanSelfUpdate)
+ {
+ knownTypes.Add(new NotificationTypeInfo
+ {
+ Type = NotificationType.ApplicationUpdateAvailable.ToString(),
+ DefaultTitle = "A new version of Emby Server is available for download."
+ });
+ }
+
+ foreach (var type in knownTypes)
+ {
+ Update(type);
+ }
+
+ var systemName = _localization.GetLocalizedString("CategorySystem");
+
+ return knownTypes.OrderByDescending(i => string.Equals(i.Category, systemName, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.Category)
+ .ThenBy(i => i.Name);
+ }
+
+ private void Update(NotificationTypeInfo note)
+ {
+ note.Name = _localization.GetLocalizedString("NotificationOption" + note.Type) ?? note.Type;
+
+ note.IsBasedOnUserEvent = note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1;
+
+ if (note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ note.Category = _localization.GetLocalizedString("CategoryUser");
+ }
+ else if (note.Type.IndexOf("Plugin", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ note.Category = _localization.GetLocalizedString("CategoryPlugin");
+ }
+ else if (note.Type.IndexOf("CameraImageUploaded", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ note.Category = _localization.GetLocalizedString("CategorySync");
+ }
+ else if (note.Type.IndexOf("UserLockedOut", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ note.Category = _localization.GetLocalizedString("CategoryUser");
+ }
+ else
+ {
+ note.Category = _localization.GetLocalizedString("CategorySystem");
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/IConfigurableNotificationService.cs b/Emby.Server.Implementations/Notifications/IConfigurableNotificationService.cs
new file mode 100644
index 000000000..d74667c48
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/IConfigurableNotificationService.cs
@@ -0,0 +1,8 @@
+namespace Emby.Server.Implementations.Notifications
+{
+ public interface IConfigurableNotificationService
+ {
+ bool IsHidden { get; }
+ bool IsEnabled(string notificationType);
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/InternalNotificationService.cs b/Emby.Server.Implementations/Notifications/InternalNotificationService.cs
new file mode 100644
index 000000000..61c564f18
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/InternalNotificationService.cs
@@ -0,0 +1,61 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Notifications;
+using System.Threading;
+using System.Threading.Tasks;
+using System;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ public class InternalNotificationService : INotificationService, IConfigurableNotificationService
+ {
+ private readonly INotificationsRepository _repo;
+
+ public InternalNotificationService(INotificationsRepository repo)
+ {
+ _repo = repo;
+ }
+
+ public string Name
+ {
+ get { return "Dashboard Notifications"; }
+ }
+
+ public Task SendNotification(UserNotification request, CancellationToken cancellationToken)
+ {
+ return _repo.AddNotification(new Notification
+ {
+ Date = request.Date,
+ Description = request.Description,
+ Level = request.Level,
+ Name = request.Name,
+ Url = request.Url,
+ UserId = request.User.Id.ToString("N")
+
+ }, cancellationToken);
+ }
+
+ public bool IsEnabledForUser(User user)
+ {
+ return user.Policy.IsAdministrator;
+ }
+
+ public bool IsHidden
+ {
+ get { return true; }
+ }
+
+ public bool IsEnabled(string notificationType)
+ {
+ if (notificationType.IndexOf("playback", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return false;
+ }
+ if (notificationType.IndexOf("newlibrarycontent", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return false;
+ }
+ return true;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/NotificationConfigurationFactory.cs b/Emby.Server.Implementations/Notifications/NotificationConfigurationFactory.cs
new file mode 100644
index 000000000..a7c5b1233
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/NotificationConfigurationFactory.cs
@@ -0,0 +1,21 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Notifications;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ public class NotificationConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ Key = "notifications",
+ ConfigurationType = typeof (NotificationOptions)
+ }
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/NotificationManager.cs b/Emby.Server.Implementations/Notifications/NotificationManager.cs
new file mode 100644
index 000000000..db7980497
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/NotificationManager.cs
@@ -0,0 +1,296 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Notifications;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ public class NotificationManager : INotificationManager
+ {
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+ private readonly IServerConfigurationManager _config;
+
+ private INotificationService[] _services;
+ private INotificationTypeFactory[] _typeFactories;
+
+ public NotificationManager(ILogManager logManager, IUserManager userManager, IServerConfigurationManager config)
+ {
+ _userManager = userManager;
+ _config = config;
+ _logger = logManager.GetLogger(GetType().Name);
+ }
+
+ private NotificationOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<NotificationOptions>("notifications");
+ }
+
+ public Task SendNotification(NotificationRequest request, CancellationToken cancellationToken)
+ {
+ var notificationType = request.NotificationType;
+
+ var options = string.IsNullOrWhiteSpace(notificationType) ?
+ null :
+ GetConfiguration().GetOptions(notificationType);
+
+ var users = GetUserIds(request, options)
+ .Select(i => _userManager.GetUserById(i));
+
+ var title = GetTitle(request, options);
+ var description = GetDescription(request, options);
+
+ var tasks = _services.Where(i => IsEnabled(i, notificationType))
+ .Select(i => SendNotification(request, i, users, title, description, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ private Task SendNotification(NotificationRequest request,
+ INotificationService service,
+ IEnumerable<User> users,
+ string title,
+ string description,
+ CancellationToken cancellationToken)
+ {
+ users = users.Where(i => IsEnabledForUser(service, i))
+ .ToList();
+
+ var tasks = users.Select(i => SendNotification(request, service, title, description, i, cancellationToken));
+
+ return Task.WhenAll(tasks);
+
+ }
+
+ private IEnumerable<string> GetUserIds(NotificationRequest request, NotificationOption options)
+ {
+ if (request.SendToUserMode.HasValue)
+ {
+ switch (request.SendToUserMode.Value)
+ {
+ case SendToUserType.Admins:
+ return _userManager.Users.Where(i => i.Policy.IsAdministrator)
+ .Select(i => i.Id.ToString("N"));
+ case SendToUserType.All:
+ return _userManager.Users.Select(i => i.Id.ToString("N"));
+ case SendToUserType.Custom:
+ return request.UserIds;
+ default:
+ throw new ArgumentException("Unrecognized SendToUserMode: " + request.SendToUserMode.Value);
+ }
+ }
+
+ if (options != null && !string.IsNullOrWhiteSpace(request.NotificationType))
+ {
+ var config = GetConfiguration();
+
+ return _userManager.Users
+ .Where(i => config.IsEnabledToSendToUser(request.NotificationType, i.Id.ToString("N"), i.Policy))
+ .Select(i => i.Id.ToString("N"));
+ }
+
+ return request.UserIds;
+ }
+
+ private async Task SendNotification(NotificationRequest request,
+ INotificationService service,
+ string title,
+ string description,
+ User user,
+ CancellationToken cancellationToken)
+ {
+ var notification = new UserNotification
+ {
+ Date = request.Date,
+ Description = description,
+ Level = request.Level,
+ Name = title,
+ Url = request.Url,
+ User = user
+ };
+
+ _logger.Debug("Sending notification via {0} to user {1}", service.Name, user.Name);
+
+ try
+ {
+ await service.SendNotification(notification, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending notification to {0}", ex, service.Name);
+ }
+ }
+
+ private string GetTitle(NotificationRequest request, NotificationOption options)
+ {
+ var title = request.Name;
+
+ // If empty, grab from options
+ if (string.IsNullOrEmpty(title))
+ {
+ if (!string.IsNullOrEmpty(request.NotificationType))
+ {
+ if (options != null)
+ {
+ title = options.Title;
+ }
+ }
+ }
+
+ // If still empty, grab default
+ if (string.IsNullOrEmpty(title))
+ {
+ if (!string.IsNullOrEmpty(request.NotificationType))
+ {
+ var info = GetNotificationTypes().FirstOrDefault(i => string.Equals(i.Type, request.NotificationType, StringComparison.OrdinalIgnoreCase));
+
+ if (info != null)
+ {
+ title = info.DefaultTitle;
+ }
+ }
+ }
+
+ title = title ?? string.Empty;
+
+ foreach (var pair in request.Variables)
+ {
+ var token = "{" + pair.Key + "}";
+
+ title = title.Replace(token, pair.Value, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return title;
+ }
+
+ private string GetDescription(NotificationRequest request, NotificationOption options)
+ {
+ var text = request.Description;
+
+ // If empty, grab from options
+ if (string.IsNullOrEmpty(text))
+ {
+ if (!string.IsNullOrEmpty(request.NotificationType))
+ {
+ if (options != null)
+ {
+ text = options.Description;
+ }
+ }
+ }
+
+ // If still empty, grab default
+ if (string.IsNullOrEmpty(text))
+ {
+ if (!string.IsNullOrEmpty(request.NotificationType))
+ {
+ var info = GetNotificationTypes().FirstOrDefault(i => string.Equals(i.Type, request.NotificationType, StringComparison.OrdinalIgnoreCase));
+
+ if (info != null)
+ {
+ text = info.DefaultDescription;
+ }
+ }
+ }
+
+ text = text ?? string.Empty;
+
+ foreach (var pair in request.Variables)
+ {
+ var token = "{" + pair.Key + "}";
+
+ text = text.Replace(token, pair.Value, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return text;
+ }
+
+ private bool IsEnabledForUser(INotificationService service, User user)
+ {
+ try
+ {
+ return service.IsEnabledForUser(user);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in IsEnabledForUser", ex);
+ return false;
+ }
+ }
+
+ private bool IsEnabled(INotificationService service, string notificationType)
+ {
+ if (string.IsNullOrEmpty(notificationType))
+ {
+ return true;
+ }
+
+ var configurable = service as IConfigurableNotificationService;
+
+ if (configurable != null)
+ {
+ return configurable.IsEnabled(notificationType);
+ }
+
+ return GetConfiguration().IsServiceEnabled(service.Name, notificationType);
+ }
+
+ public void AddParts(IEnumerable<INotificationService> services, IEnumerable<INotificationTypeFactory> notificationTypeFactories)
+ {
+ _services = services.ToArray();
+ _typeFactories = notificationTypeFactories.ToArray();
+ }
+
+ public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
+ {
+ var list = _typeFactories.Select(i =>
+ {
+ try
+ {
+ return i.GetNotificationTypes().ToList();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in GetNotificationTypes", ex);
+ return new List<NotificationTypeInfo>();
+ }
+
+ }).SelectMany(i => i).ToList();
+
+ var config = GetConfiguration();
+
+ foreach (var i in list)
+ {
+ i.Enabled = config.IsEnabled(i.Type);
+ }
+
+ return list;
+ }
+
+ public IEnumerable<NotificationServiceInfo> GetNotificationServices()
+ {
+ return _services.Where(i =>
+ {
+ var configurable = i as IConfigurableNotificationService;
+
+ return configurable == null || !configurable.IsHidden;
+
+ }).Select(i => new NotificationServiceInfo
+ {
+ Name = i.Name,
+ Id = i.Name.GetMD5().ToString("N")
+
+ }).OrderBy(i => i.Name);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/Notifications.cs b/Emby.Server.Implementations/Notifications/Notifications.cs
new file mode 100644
index 000000000..2d441c18c
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/Notifications.cs
@@ -0,0 +1,547 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Notifications;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Updates;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ /// <summary>
+ /// Creates notifications for various system events
+ /// </summary>
+ public class Notifications : IServerEntryPoint
+ {
+ private readonly IInstallationManager _installationManager;
+ private readonly IUserManager _userManager;
+ private readonly ILogger _logger;
+
+ private readonly ITaskManager _taskManager;
+ private readonly INotificationManager _notificationManager;
+
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ITimerFactory _timerFactory;
+
+ private ITimer LibraryUpdateTimer { get; set; }
+ private readonly object _libraryChangedSyncLock = new object();
+
+ private readonly IConfigurationManager _config;
+ private readonly IDeviceManager _deviceManager;
+
+ public Notifications(IInstallationManager installationManager, IUserManager userManager, ILogger logger, ITaskManager taskManager, INotificationManager notificationManager, ILibraryManager libraryManager, ISessionManager sessionManager, IServerApplicationHost appHost, IConfigurationManager config, IDeviceManager deviceManager, ITimerFactory timerFactory)
+ {
+ _installationManager = installationManager;
+ _userManager = userManager;
+ _logger = logger;
+ _taskManager = taskManager;
+ _notificationManager = notificationManager;
+ _libraryManager = libraryManager;
+ _sessionManager = sessionManager;
+ _appHost = appHost;
+ _config = config;
+ _deviceManager = deviceManager;
+ _timerFactory = timerFactory;
+ }
+
+ public void Run()
+ {
+ _installationManager.PluginInstalled += _installationManager_PluginInstalled;
+ _installationManager.PluginUpdated += _installationManager_PluginUpdated;
+ _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed;
+ _installationManager.PluginUninstalled += _installationManager_PluginUninstalled;
+
+ _taskManager.TaskCompleted += _taskManager_TaskCompleted;
+
+ _userManager.UserCreated += _userManager_UserCreated;
+ _libraryManager.ItemAdded += _libraryManager_ItemAdded;
+ _sessionManager.PlaybackStart += _sessionManager_PlaybackStart;
+ _sessionManager.PlaybackStopped += _sessionManager_PlaybackStopped;
+ _appHost.HasPendingRestartChanged += _appHost_HasPendingRestartChanged;
+ _appHost.HasUpdateAvailableChanged += _appHost_HasUpdateAvailableChanged;
+ _appHost.ApplicationUpdated += _appHost_ApplicationUpdated;
+ _deviceManager.CameraImageUploaded += _deviceManager_CameraImageUploaded;
+
+ _userManager.UserLockedOut += _userManager_UserLockedOut;
+ }
+
+ async void _userManager_UserLockedOut(object sender, GenericEventArgs<User> e)
+ {
+ var type = NotificationType.UserLockedOut.ToString();
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type
+ };
+
+ notification.Variables["UserName"] = e.Argument.Name;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _deviceManager_CameraImageUploaded(object sender, GenericEventArgs<CameraImageUploadInfo> e)
+ {
+ var type = NotificationType.CameraImageUploaded.ToString();
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type
+ };
+
+ notification.Variables["DeviceName"] = e.Argument.Device.Name;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _appHost_ApplicationUpdated(object sender, GenericEventArgs<PackageVersionInfo> e)
+ {
+ var type = NotificationType.ApplicationUpdateInstalled.ToString();
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type,
+ Url = e.Argument.infoUrl
+ };
+
+ notification.Variables["Version"] = e.Argument.versionStr;
+ notification.Variables["ReleaseNotes"] = e.Argument.description;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _installationManager_PluginUpdated(object sender, GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> e)
+ {
+ var type = NotificationType.PluginUpdateInstalled.ToString();
+
+ var installationInfo = e.Argument.Item1;
+
+ var notification = new NotificationRequest
+ {
+ Description = e.Argument.Item2.description,
+ NotificationType = type
+ };
+
+ notification.Variables["Name"] = installationInfo.Name;
+ notification.Variables["Version"] = installationInfo.Version.ToString();
+ notification.Variables["ReleaseNotes"] = e.Argument.Item2.description;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _installationManager_PluginInstalled(object sender, GenericEventArgs<PackageVersionInfo> e)
+ {
+ var type = NotificationType.PluginInstalled.ToString();
+
+ var installationInfo = e.Argument;
+
+ var notification = new NotificationRequest
+ {
+ Description = installationInfo.description,
+ NotificationType = type
+ };
+
+ notification.Variables["Name"] = installationInfo.name;
+ notification.Variables["Version"] = installationInfo.versionStr;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _appHost_HasUpdateAvailableChanged(object sender, EventArgs e)
+ {
+ // This notification is for users who can't auto-update (aka running as service)
+ if (!_appHost.HasUpdateAvailable || _appHost.CanSelfUpdate)
+ {
+ return;
+ }
+
+ var type = NotificationType.ApplicationUpdateAvailable.ToString();
+
+ var notification = new NotificationRequest
+ {
+ Description = "Please see emby.media for details.",
+ NotificationType = type
+ };
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _appHost_HasPendingRestartChanged(object sender, EventArgs e)
+ {
+ if (!_appHost.HasPendingRestart)
+ {
+ return;
+ }
+
+ var type = NotificationType.ServerRestartRequired.ToString();
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type
+ };
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ private NotificationOptions GetOptions()
+ {
+ return _config.GetConfiguration<NotificationOptions>("notifications");
+ }
+
+ void _sessionManager_PlaybackStart(object sender, PlaybackProgressEventArgs e)
+ {
+ var item = e.MediaInfo;
+
+ if (item == null)
+ {
+ _logger.Warn("PlaybackStart reported with null media info.");
+ return;
+ }
+
+ var video = e.Item as Video;
+ if (video != null && video.IsThemeMedia)
+ {
+ return;
+ }
+
+ var type = GetPlaybackNotificationType(item.MediaType);
+
+ SendPlaybackNotification(type, e);
+ }
+
+ void _sessionManager_PlaybackStopped(object sender, PlaybackStopEventArgs e)
+ {
+ var item = e.MediaInfo;
+
+ if (item == null)
+ {
+ _logger.Warn("PlaybackStopped reported with null media info.");
+ return;
+ }
+
+ var video = e.Item as Video;
+ if (video != null && video.IsThemeMedia)
+ {
+ return;
+ }
+
+ var type = GetPlaybackStoppedNotificationType(item.MediaType);
+
+ SendPlaybackNotification(type, e);
+ }
+
+ private async void SendPlaybackNotification(string type, PlaybackProgressEventArgs e)
+ {
+ var user = e.Users.FirstOrDefault();
+
+ if (user != null && !GetOptions().IsEnabledToMonitorUser(type, user.Id.ToString("N")))
+ {
+ return;
+ }
+
+ var item = e.MediaInfo;
+
+ if ( item.IsThemeMedia)
+ {
+ // Don't report theme song or local trailer playback
+ return;
+ }
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type
+ };
+
+ if (e.Item != null)
+ {
+ notification.Variables["ItemName"] = GetItemName(e.Item);
+ }
+ else
+ {
+ notification.Variables["ItemName"] = item.Name;
+ }
+
+ notification.Variables["UserName"] = user == null ? "Unknown user" : user.Name;
+ notification.Variables["AppName"] = e.ClientName;
+ notification.Variables["DeviceName"] = e.DeviceName;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ private string GetPlaybackNotificationType(string mediaType)
+ {
+ if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.AudioPlayback.ToString();
+ }
+ if (string.Equals(mediaType, MediaType.Game, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.GamePlayback.ToString();
+ }
+ if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.VideoPlayback.ToString();
+ }
+
+ return null;
+ }
+
+ private string GetPlaybackStoppedNotificationType(string mediaType)
+ {
+ if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.AudioPlaybackStopped.ToString();
+ }
+ if (string.Equals(mediaType, MediaType.Game, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.GamePlaybackStopped.ToString();
+ }
+ if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.VideoPlaybackStopped.ToString();
+ }
+
+ return null;
+ }
+
+ private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
+ void _libraryManager_ItemAdded(object sender, ItemChangeEventArgs e)
+ {
+ if (!FilterItem(e.Item))
+ {
+ return;
+ }
+
+ lock (_libraryChangedSyncLock)
+ {
+ if (LibraryUpdateTimer == null)
+ {
+ LibraryUpdateTimer = _timerFactory.Create(LibraryUpdateTimerCallback, null, 5000,
+ Timeout.Infinite);
+ }
+ else
+ {
+ LibraryUpdateTimer.Change(5000, Timeout.Infinite);
+ }
+
+ _itemsAdded.Add(e.Item);
+ }
+ }
+
+ private bool FilterItem(BaseItem item)
+ {
+ if (item.IsFolder)
+ {
+ return false;
+ }
+
+ if (item.LocationType == LocationType.Virtual)
+ {
+ return false;
+ }
+
+ if (item is IItemByName)
+ {
+ return false;
+ }
+
+ return item.SourceType == SourceType.Library;
+ }
+
+ private async void LibraryUpdateTimerCallback(object state)
+ {
+ List<BaseItem> items;
+
+ lock (_libraryChangedSyncLock)
+ {
+ items = _itemsAdded.ToList();
+ _itemsAdded.Clear();
+ DisposeLibraryUpdateTimer();
+ }
+
+ items = items.Take(10).ToList();
+
+ foreach (var item in items)
+ {
+ var notification = new NotificationRequest
+ {
+ NotificationType = NotificationType.NewLibraryContent.ToString()
+ };
+
+ notification.Variables["Name"] = GetItemName(item);
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+ }
+
+ public static string GetItemName(BaseItem item)
+ {
+ var name = item.Name;
+ var episode = item as Episode;
+ if (episode != null)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ name = string.Format("Ep{0} - {1}", episode.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), name);
+ }
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ name = string.Format("S{0}, {1}", episode.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture), name);
+ }
+ }
+
+ var hasSeries = item as IHasSeries;
+
+ if (hasSeries != null)
+ {
+ name = hasSeries.SeriesName + " - " + name;
+ }
+
+ var hasArtist = item as IHasArtist;
+ if (hasArtist != null)
+ {
+ var artists = hasArtist.AllArtists;
+
+ if (artists.Count > 0)
+ {
+ name = hasArtist.AllArtists[0] + " - " + name;
+ }
+ }
+
+ return name;
+ }
+
+ async void _userManager_UserCreated(object sender, GenericEventArgs<User> e)
+ {
+ var notification = new NotificationRequest
+ {
+ UserIds = new List<string> { e.Argument.Id.ToString("N") },
+ Name = "Welcome to Emby!",
+ Description = "Check back here for more notifications."
+ };
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e)
+ {
+ var result = e.Result;
+
+ if (result.Status == TaskCompletionStatus.Failed)
+ {
+ var type = NotificationType.TaskFailed.ToString();
+
+ var notification = new NotificationRequest
+ {
+ Description = result.ErrorMessage,
+ Level = NotificationLevel.Error,
+ NotificationType = type
+ };
+
+ notification.Variables["Name"] = result.Name;
+ notification.Variables["ErrorMessage"] = result.ErrorMessage;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+ }
+
+ async void _installationManager_PluginUninstalled(object sender, GenericEventArgs<IPlugin> e)
+ {
+ var type = NotificationType.PluginUninstalled.ToString();
+
+ var plugin = e.Argument;
+
+ var notification = new NotificationRequest
+ {
+ NotificationType = type
+ };
+
+ notification.Variables["Name"] = plugin.Name;
+ notification.Variables["Version"] = plugin.Version.ToString();
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ async void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e)
+ {
+ var installationInfo = e.InstallationInfo;
+
+ var type = NotificationType.InstallationFailed.ToString();
+
+ var notification = new NotificationRequest
+ {
+ Level = NotificationLevel.Error,
+ Description = e.Exception.Message,
+ NotificationType = type
+ };
+
+ notification.Variables["Name"] = installationInfo.Name;
+ notification.Variables["Version"] = installationInfo.Version;
+
+ await SendNotification(notification).ConfigureAwait(false);
+ }
+
+ private async Task SendNotification(NotificationRequest notification)
+ {
+ try
+ {
+ await _notificationManager.SendNotification(notification, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending notification", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ DisposeLibraryUpdateTimer();
+
+ _installationManager.PluginInstalled -= _installationManager_PluginInstalled;
+ _installationManager.PluginUpdated -= _installationManager_PluginUpdated;
+ _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed;
+ _installationManager.PluginUninstalled -= _installationManager_PluginUninstalled;
+
+ _taskManager.TaskCompleted -= _taskManager_TaskCompleted;
+
+ _userManager.UserCreated -= _userManager_UserCreated;
+ _libraryManager.ItemAdded -= _libraryManager_ItemAdded;
+ _sessionManager.PlaybackStart -= _sessionManager_PlaybackStart;
+
+ _appHost.HasPendingRestartChanged -= _appHost_HasPendingRestartChanged;
+ _appHost.HasUpdateAvailableChanged -= _appHost_HasUpdateAvailableChanged;
+ _appHost.ApplicationUpdated -= _appHost_ApplicationUpdated;
+
+ _deviceManager.CameraImageUploaded -= _deviceManager_CameraImageUploaded;
+ _userManager.UserLockedOut -= _userManager_UserLockedOut;
+ }
+
+ private void DisposeLibraryUpdateTimer()
+ {
+ if (LibraryUpdateTimer != null)
+ {
+ LibraryUpdateTimer.Dispose();
+ LibraryUpdateTimer = null;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/SqliteNotificationsRepository.cs b/Emby.Server.Implementations/Notifications/SqliteNotificationsRepository.cs
new file mode 100644
index 000000000..f18278cb2
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/SqliteNotificationsRepository.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Notifications;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ public class SqliteNotificationsRepository : BaseSqliteRepository, INotificationsRepository
+ {
+ public SqliteNotificationsRepository(ILogger logger, IServerApplicationPaths appPaths) : base(logger)
+ {
+ DbFilePath = Path.Combine(appPaths.DataPath, "notifications.db");
+ }
+
+ public event EventHandler<NotificationUpdateEventArgs> NotificationAdded;
+ public event EventHandler<NotificationReadEventArgs> NotificationsMarkedRead;
+ ////public event EventHandler<NotificationUpdateEventArgs> NotificationUpdated;
+
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists Notifications (Id GUID NOT NULL, UserId GUID NOT NULL, Date DATETIME NOT NULL, Name TEXT NOT NULL, Description TEXT NULL, Url TEXT NULL, Level TEXT NOT NULL, IsRead BOOLEAN NOT NULL, Category TEXT NOT NULL, RelatedId TEXT NULL, PRIMARY KEY (Id, UserId))",
+ "create index if not exists idx_Notifications1 on Notifications(Id)",
+ "create index if not exists idx_Notifications2 on Notifications(UserId)"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ /// <summary>
+ /// Gets the notifications.
+ /// </summary>
+ /// <param name="query">The query.</param>
+ /// <returns>NotificationResult.</returns>
+ public NotificationResult GetNotifications(NotificationQuery query)
+ {
+ var result = new NotificationResult();
+
+ var clauses = new List<string>();
+ var paramList = new List<object>();
+
+ if (query.IsRead.HasValue)
+ {
+ clauses.Add("IsRead=?");
+ paramList.Add(query.IsRead.Value);
+ }
+
+ clauses.Add("UserId=?");
+ paramList.Add(query.UserId.ToGuidParamValue());
+
+ var whereClause = " where " + string.Join(" And ", clauses.ToArray());
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ result.TotalRecordCount = connection.Query("select count(Id) from Notifications" + whereClause, paramList.ToArray()).SelectScalarInt().First();
+
+ var commandText = string.Format("select Id,UserId,Date,Name,Description,Url,Level,IsRead,Category,RelatedId from Notifications{0} order by IsRead asc, Date desc", whereClause);
+
+ if (query.Limit.HasValue || query.StartIndex.HasValue)
+ {
+ var offset = query.StartIndex ?? 0;
+
+ if (query.Limit.HasValue || offset > 0)
+ {
+ commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (offset > 0)
+ {
+ commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture);
+ }
+ }
+
+ var resultList = new List<Notification>();
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ resultList.Add(GetNotification(row));
+ }
+
+ result.Notifications = resultList.ToArray();
+ }
+ }
+
+ return result;
+ }
+
+ public NotificationsSummary GetNotificationsSummary(string userId)
+ {
+ var result = new NotificationsSummary();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ using (var statement = connection.PrepareStatement("select Level from Notifications where UserId=@UserId and IsRead=@IsRead"))
+ {
+ statement.TryBind("@IsRead", false);
+ statement.TryBind("@UserId", userId.ToGuidParamValue());
+
+ var levels = new List<NotificationLevel>();
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ levels.Add(GetLevel(row, 0));
+ }
+
+ result.UnreadCount = levels.Count;
+
+ if (levels.Count > 0)
+ {
+ result.MaxUnreadNotificationLevel = levels.Max();
+ }
+ }
+
+ return result;
+ }
+ }
+ }
+
+ private Notification GetNotification(IReadOnlyList<IResultSetValue> reader)
+ {
+ var notification = new Notification
+ {
+ Id = reader[0].ReadGuid().ToString("N"),
+ UserId = reader[1].ReadGuid().ToString("N"),
+ Date = reader[2].ReadDateTime(),
+ Name = reader[3].ToString()
+ };
+
+ if (reader[4].SQLiteType != SQLiteType.Null)
+ {
+ notification.Description = reader[4].ToString();
+ }
+
+ if (reader[5].SQLiteType != SQLiteType.Null)
+ {
+ notification.Url = reader[5].ToString();
+ }
+
+ notification.Level = GetLevel(reader, 6);
+ notification.IsRead = reader[7].ToBool();
+
+ return notification;
+ }
+
+ /// <summary>
+ /// Gets the level.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <param name="index">The index.</param>
+ /// <returns>NotificationLevel.</returns>
+ private NotificationLevel GetLevel(IReadOnlyList<IResultSetValue> reader, int index)
+ {
+ NotificationLevel level;
+
+ var val = reader[index].ToString();
+
+ Enum.TryParse(val, true, out level);
+
+ return level;
+ }
+
+ /// <summary>
+ /// Adds the notification.
+ /// </summary>
+ /// <param name="notification">The notification.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task AddNotification(Notification notification, CancellationToken cancellationToken)
+ {
+ await ReplaceNotification(notification, cancellationToken).ConfigureAwait(false);
+
+ if (NotificationAdded != null)
+ {
+ try
+ {
+ NotificationAdded(this, new NotificationUpdateEventArgs
+ {
+ Notification = notification
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error in NotificationAdded event handler", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Replaces the notification.
+ /// </summary>
+ /// <param name="notification">The notification.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task ReplaceNotification(Notification notification, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(notification.Id))
+ {
+ notification.Id = Guid.NewGuid().ToString("N");
+ }
+ if (string.IsNullOrEmpty(notification.UserId))
+ {
+ throw new ArgumentException("The notification must have a user id");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ lock (WriteLock)
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(conn =>
+ {
+ using (var statement = conn.PrepareStatement("replace into Notifications (Id, UserId, Date, Name, Description, Url, Level, IsRead, Category, RelatedId) values (@Id, @UserId, @Date, @Name, @Description, @Url, @Level, @IsRead, @Category, @RelatedId)"))
+ {
+ statement.TryBind("@Id", notification.Id.ToGuidParamValue());
+ statement.TryBind("@UserId", notification.UserId.ToGuidParamValue());
+ statement.TryBind("@Date", notification.Date.ToDateTimeParamValue());
+ statement.TryBind("@Name", notification.Name);
+ statement.TryBind("@Description", notification.Description);
+ statement.TryBind("@Url", notification.Url);
+ statement.TryBind("@Level", notification.Level.ToString());
+ statement.TryBind("@IsRead", notification.IsRead);
+ statement.TryBind("@Category", string.Empty);
+ statement.TryBind("@RelatedId", string.Empty);
+
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Marks the read.
+ /// </summary>
+ /// <param name="notificationIdList">The notification id list.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="isRead">if set to <c>true</c> [is read].</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task MarkRead(IEnumerable<string> notificationIdList, string userId, bool isRead, CancellationToken cancellationToken)
+ {
+ var list = notificationIdList.ToList();
+ var idArray = list.Select(i => new Guid(i)).ToArray();
+
+ await MarkReadInternal(idArray, userId, isRead, cancellationToken).ConfigureAwait(false);
+
+ if (NotificationsMarkedRead != null)
+ {
+ try
+ {
+ NotificationsMarkedRead(this, new NotificationReadEventArgs
+ {
+ IdList = list.ToArray(),
+ IsRead = isRead,
+ UserId = userId
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.ErrorException("Error in NotificationsMarkedRead event handler", ex);
+ }
+ }
+ }
+
+ public async Task MarkAllRead(string userId, bool isRead, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(conn =>
+ {
+ using (var statement = conn.PrepareStatement("update Notifications set IsRead=@IsRead where UserId=@UserId"))
+ {
+ statement.TryBind("@IsRead", isRead);
+ statement.TryBind("@UserId", userId.ToGuidParamValue());
+
+ statement.MoveNext();
+ }
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private async Task MarkReadInternal(IEnumerable<Guid> notificationIdList, string userId, bool isRead, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(conn =>
+ {
+ using (var statement = conn.PrepareStatement("update Notifications set IsRead=@IsRead where UserId=@UserId and Id=@Id"))
+ {
+ statement.TryBind("@IsRead", isRead);
+ statement.TryBind("@UserId", userId.ToGuidParamValue());
+
+ foreach (var id in notificationIdList)
+ {
+ statement.Reset();
+
+ statement.TryBind("@Id", id.ToGuidParamValue());
+
+ statement.MoveNext();
+ }
+ }
+
+ }, TransactionMode);
+ }
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Notifications/WebSocketNotifier.cs b/Emby.Server.Implementations/Notifications/WebSocketNotifier.cs
new file mode 100644
index 000000000..8b3367217
--- /dev/null
+++ b/Emby.Server.Implementations/Notifications/WebSocketNotifier.cs
@@ -0,0 +1,54 @@
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Controller.Plugins;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Notifications
+{
+ /// <summary>
+ /// Notifies clients anytime a notification is added or udpated
+ /// </summary>
+ public class WebSocketNotifier : IServerEntryPoint
+ {
+ private readonly INotificationsRepository _notificationsRepo;
+
+ private readonly IServerManager _serverManager;
+
+ public WebSocketNotifier(INotificationsRepository notificationsRepo, IServerManager serverManager)
+ {
+ _notificationsRepo = notificationsRepo;
+ _serverManager = serverManager;
+ }
+
+ public void Run()
+ {
+ _notificationsRepo.NotificationAdded += _notificationsRepo_NotificationAdded;
+
+ _notificationsRepo.NotificationsMarkedRead += _notificationsRepo_NotificationsMarkedRead;
+ }
+
+ void _notificationsRepo_NotificationsMarkedRead(object sender, NotificationReadEventArgs e)
+ {
+ var list = e.IdList.ToList();
+
+ list.Add(e.UserId);
+ list.Add(e.IsRead.ToString().ToLower());
+
+ var msg = string.Join("|", list.ToArray());
+
+ _serverManager.SendWebSocketMessage("NotificationsMarkedRead", msg);
+ }
+
+ void _notificationsRepo_NotificationAdded(object sender, NotificationUpdateEventArgs e)
+ {
+ var msg = e.Notification.UserId + "|" + e.Notification.Id;
+
+ _serverManager.SendWebSocketMessage("NotificationAdded", msg);
+ }
+
+ public void Dispose()
+ {
+ _notificationsRepo.NotificationAdded -= _notificationsRepo_NotificationAdded;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Photos/PhotoAlbumImageProvider.cs b/Emby.Server.Implementations/Photos/PhotoAlbumImageProvider.cs
new file mode 100644
index 000000000..cc1756f96
--- /dev/null
+++ b/Emby.Server.Implementations/Photos/PhotoAlbumImageProvider.cs
@@ -0,0 +1,34 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Images;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Entities;
+
+namespace Emby.Server.Implementations.Photos
+{
+ public class PhotoAlbumImageProvider : BaseDynamicImageProvider<PhotoAlbum>
+ {
+ public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor)
+ : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ }
+
+ protected override Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var photoAlbum = (PhotoAlbum)item;
+ var items = GetFinalItems(photoAlbum.Children.ToList());
+
+ return Task.FromResult(items);
+ }
+
+ protected override Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ {
+ return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs b/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs
new file mode 100644
index 000000000..ef7d6dba8
--- /dev/null
+++ b/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs
@@ -0,0 +1,104 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Images;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Playlists
+{
+ public class PlaylistImageProvider : BaseDynamicImageProvider<Playlist>
+ {
+ public PlaylistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ }
+
+ protected override Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var playlist = (Playlist)item;
+
+ var items = playlist.GetManageableItems()
+ .Select(i =>
+ {
+ var subItem = i.Item2;
+
+ var episode = subItem as Episode;
+
+ if (episode != null)
+ {
+ var series = episode.Series;
+ if (series != null && series.HasImage(ImageType.Primary))
+ {
+ return series;
+ }
+ }
+
+ if (subItem.HasImage(ImageType.Primary))
+ {
+ return subItem;
+ }
+
+ var parent = subItem.GetParent();
+
+ if (parent != null && parent.HasImage(ImageType.Primary))
+ {
+ if (parent is MusicAlbum)
+ {
+ return parent;
+ }
+ }
+
+ return null;
+ })
+ .Where(i => i != null)
+ .DistinctBy(i => i.Id)
+ .ToList();
+
+ return Task.FromResult(GetFinalItems(items));
+ }
+ }
+
+ public class MusicGenreImageProvider : BaseDynamicImageProvider<MusicGenre>
+ {
+ private readonly ILibraryManager _libraryManager;
+
+ public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ _libraryManager = libraryManager;
+ }
+
+ protected override Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Genres = new[] { item.Name },
+ IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name },
+ SortBy = new[] { ItemSortBy.Random },
+ Limit = 4,
+ Recursive = true,
+ ImageTypes = new[] { ImageType.Primary }
+
+ }).ToList();
+
+ return Task.FromResult(GetFinalItems(items));
+ }
+
+ //protected override Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ //{
+ // return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary);
+ //}
+ }
+
+}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
new file mode 100644
index 000000000..9583141e0
--- /dev/null
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -0,0 +1,276 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Playlists;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Playlists
+{
+ public class PlaylistManager : IPlaylistManager
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryMonitor _iLibraryMonitor;
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+ private readonly IProviderManager _providerManager;
+
+ public PlaylistManager(ILibraryManager libraryManager, IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILogger logger, IUserManager userManager, IProviderManager providerManager)
+ {
+ _libraryManager = libraryManager;
+ _fileSystem = fileSystem;
+ _iLibraryMonitor = iLibraryMonitor;
+ _logger = logger;
+ _userManager = userManager;
+ _providerManager = providerManager;
+ }
+
+ public IEnumerable<Playlist> GetPlaylists(string userId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>();
+ }
+
+ public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options)
+ {
+ var name = options.Name;
+
+ var folderName = _fileSystem.GetValidFilename(name) + " [playlist]";
+
+ var parentFolder = GetPlaylistsFolder(null);
+
+ if (parentFolder == null)
+ {
+ throw new ArgumentException();
+ }
+
+ if (string.IsNullOrWhiteSpace(options.MediaType))
+ {
+ foreach (var itemId in options.ItemIdList)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item == null)
+ {
+ throw new ArgumentException("No item exists with the supplied Id");
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.MediaType))
+ {
+ options.MediaType = item.MediaType;
+ }
+ else if (item is MusicArtist || item is MusicAlbum || item is MusicGenre)
+ {
+ options.MediaType = MediaType.Audio;
+ }
+ else if (item is Genre)
+ {
+ options.MediaType = MediaType.Video;
+ }
+ else
+ {
+ var folder = item as Folder;
+ if (folder != null)
+ {
+ options.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist)
+ .Select(i => i.MediaType)
+ .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.MediaType))
+ {
+ break;
+ }
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(options.MediaType))
+ {
+ throw new ArgumentException("A playlist media type is required.");
+ }
+
+ var user = _userManager.GetUserById(options.UserId);
+
+ var path = Path.Combine(parentFolder.Path, folderName);
+ path = GetTargetPath(path);
+
+ _iLibraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ try
+ {
+ _fileSystem.CreateDirectory(path);
+
+ var playlist = new Playlist
+ {
+ Name = name,
+ Path = path
+ };
+
+ playlist.Shares.Add(new Share
+ {
+ UserId = options.UserId,
+ CanEdit = true
+ });
+
+ playlist.SetMediaType(options.MediaType);
+
+ await parentFolder.AddChild(playlist, CancellationToken.None).ConfigureAwait(false);
+
+ await playlist.RefreshMetadata(new MetadataRefreshOptions(_fileSystem) { ForceSave = true }, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (options.ItemIdList.Count > 0)
+ {
+ await AddToPlaylistInternal(playlist.Id.ToString("N"), options.ItemIdList, user);
+ }
+
+ return new PlaylistCreationResult
+ {
+ Id = playlist.Id.ToString("N")
+ };
+ }
+ finally
+ {
+ // Refresh handled internally
+ _iLibraryMonitor.ReportFileSystemChangeComplete(path, false);
+ }
+ }
+
+ private string GetTargetPath(string path)
+ {
+ while (_fileSystem.DirectoryExists(path))
+ {
+ path += "1";
+ }
+
+ return path;
+ }
+
+ private Task<IEnumerable<BaseItem>> GetPlaylistItems(IEnumerable<string> itemIds, string playlistMediaType, User user)
+ {
+ var items = itemIds.Select(i => _libraryManager.GetItemById(i)).Where(i => i != null);
+
+ return Playlist.GetPlaylistItems(playlistMediaType, items, user);
+ }
+
+ public Task AddToPlaylist(string playlistId, IEnumerable<string> itemIds, string userId)
+ {
+ var user = string.IsNullOrWhiteSpace(userId) ? null : _userManager.GetUserById(userId);
+
+ return AddToPlaylistInternal(playlistId, itemIds, user);
+ }
+
+ private async Task AddToPlaylistInternal(string playlistId, IEnumerable<string> itemIds, User user)
+ {
+ var playlist = _libraryManager.GetItemById(playlistId) as Playlist;
+
+ if (playlist == null)
+ {
+ throw new ArgumentException("No Playlist exists with the supplied Id");
+ }
+
+ var list = new List<LinkedChild>();
+
+ var items = (await GetPlaylistItems(itemIds, playlist.MediaType, user).ConfigureAwait(false))
+ .Where(i => i.SupportsAddingToPlaylist)
+ .ToList();
+
+ foreach (var item in items)
+ {
+ list.Add(LinkedChild.Create(item));
+ }
+
+ playlist.LinkedChildren.AddRange(list);
+
+ await playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ _providerManager.QueueRefresh(playlist.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ ForceSave = true
+ });
+ }
+
+ public async Task RemoveFromPlaylist(string playlistId, IEnumerable<string> entryIds)
+ {
+ var playlist = _libraryManager.GetItemById(playlistId) as Playlist;
+
+ if (playlist == null)
+ {
+ throw new ArgumentException("No Playlist exists with the supplied Id");
+ }
+
+ var children = playlist.GetManageableItems().ToList();
+
+ var idList = entryIds.ToList();
+
+ var removals = children.Where(i => idList.Contains(i.Item1.Id));
+
+ playlist.LinkedChildren = children.Except(removals)
+ .Select(i => i.Item1)
+ .ToList();
+
+ await playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ _providerManager.QueueRefresh(playlist.Id, new MetadataRefreshOptions(_fileSystem)
+ {
+ ForceSave = true
+ });
+ }
+
+ public async Task MoveItem(string playlistId, string entryId, int newIndex)
+ {
+ var playlist = _libraryManager.GetItemById(playlistId) as Playlist;
+
+ if (playlist == null)
+ {
+ throw new ArgumentException("No Playlist exists with the supplied Id");
+ }
+
+ var children = playlist.GetManageableItems().ToList();
+
+ var oldIndex = children.FindIndex(i => string.Equals(entryId, i.Item1.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (oldIndex == newIndex)
+ {
+ return;
+ }
+
+ var item = playlist.LinkedChildren[oldIndex];
+
+ playlist.LinkedChildren.Remove(item);
+
+ if (newIndex >= playlist.LinkedChildren.Count)
+ {
+ playlist.LinkedChildren.Add(item);
+ }
+ else
+ {
+ playlist.LinkedChildren.Insert(newIndex, item);
+ }
+
+ await playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ public Folder GetPlaylistsFolder(string userId)
+ {
+ var typeName = "PlaylistsFolder";
+
+ return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)) ??
+ _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal));
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistsDynamicFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsDynamicFolder.cs
new file mode 100644
index 000000000..dacc937e1
--- /dev/null
+++ b/Emby.Server.Implementations/Playlists/PlaylistsDynamicFolder.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Server.Implementations.Playlists;
+
+namespace Emby.Server.Implementations.Playlists
+{
+ public class PlaylistsDynamicFolder : IVirtualFolderCreator
+ {
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+
+ public PlaylistsDynamicFolder(IApplicationPaths appPaths, IFileSystem fileSystem)
+ {
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ }
+
+ public BasePluginFolder GetFolder()
+ {
+ var path = Path.Combine(_appPaths.DataPath, "playlists");
+
+ _fileSystem.CreateDirectory(path);
+
+ return new PlaylistsFolder
+ {
+ Path = path
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..ed7f9631f
--- /dev/null
+++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs
@@ -0,0 +1,30 @@
+using System.Resources;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("Emby.Server.Implementations")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("Emby.Server.Implementations")]
+[assembly: AssemblyCopyright("Copyright © 2016")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+[assembly: NeutralResourcesLanguage("en")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/Emby.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs
new file mode 100644
index 000000000..d75815847
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs
@@ -0,0 +1,192 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Class ChapterImagesTask
+ /// </summary>
+ class ChapterImagesTask : IScheduledTask
+ {
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ private readonly IItemRepository _itemRepo;
+
+ private readonly IApplicationPaths _appPaths;
+
+ private readonly IEncodingManager _encodingManager;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class.
+ /// </summary>
+ public ChapterImagesTask(ILogManager logManager, ILibraryManager libraryManager, IItemRepository itemRepo, IApplicationPaths appPaths, IEncodingManager encodingManager, IFileSystem fileSystem)
+ {
+ _logger = logManager.GetLogger(GetType().Name);
+ _libraryManager = libraryManager;
+ _itemRepo = itemRepo;
+ _appPaths = appPaths;
+ _encodingManager = encodingManager;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfo.TriggerDaily,
+ TimeOfDayTicks = TimeSpan.FromHours(1).Ticks,
+ MaxRuntimeMs = Convert.ToInt32(TimeSpan.FromHours(4).TotalMilliseconds)
+ }
+ };
+ }
+
+ public string Key
+ {
+ get { return "RefreshChapterImages"; }
+ }
+
+ /// <summary>
+ /// Returns the task to be executed
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var videos = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ MediaTypes = new[] { MediaType.Video },
+ IsFolder = false,
+ Recursive = true
+ })
+ .OfType<Video>()
+ .ToList();
+
+ var numComplete = 0;
+
+ var failHistoryPath = Path.Combine(_appPaths.CachePath, "chapter-failures.txt");
+
+ List<string> previouslyFailedImages;
+
+ try
+ {
+ previouslyFailedImages = _fileSystem.ReadAllText(failHistoryPath)
+ .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
+ .ToList();
+ }
+ catch (FileNotFoundException)
+ {
+ previouslyFailedImages = new List<string>();
+ }
+ catch (IOException)
+ {
+ previouslyFailedImages = new List<string>();
+ }
+
+ foreach (var video in videos)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var key = video.Path + video.DateModified.Ticks;
+
+ var extract = !previouslyFailedImages.Contains(key, StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ var chapters = _itemRepo.GetChapters(video.Id).ToList();
+
+ var success = await _encodingManager.RefreshChapterImages(new ChapterImageRefreshOptions
+ {
+ SaveChapters = true,
+ ExtractImages = extract,
+ Video = video,
+ Chapters = chapters
+
+ }, CancellationToken.None);
+
+ if (!success)
+ {
+ previouslyFailedImages.Add(key);
+
+ var parentPath = Path.GetDirectoryName(failHistoryPath);
+
+ _fileSystem.CreateDirectory(parentPath);
+
+ _fileSystem.WriteAllText(failHistoryPath, string.Join("|", previouslyFailedImages.ToArray()));
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= videos.Count;
+
+ progress.Report(100 * percent);
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the name of the task
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get
+ {
+ return "Chapter image extraction";
+ }
+ }
+
+ /// <summary>
+ /// Gets the description.
+ /// </summary>
+ /// <value>The description.</value>
+ public string Description
+ {
+ get { return "Creates thumbnails for videos that have chapters."; }
+ }
+
+ /// <summary>
+ /// Gets the category.
+ /// </summary>
+ /// <value>The category.</value>
+ public string Category
+ {
+ get
+ {
+ return "Library";
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/PeopleValidationTask.cs
new file mode 100644
index 000000000..02568fe3a
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/PeopleValidationTask.cs
@@ -0,0 +1,95 @@
+using MediaBrowser.Controller.Library;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Class PeopleValidationTask
+ /// </summary>
+ public class PeopleValidationTask : IScheduledTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ private readonly IServerApplicationHost _appHost;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ public PeopleValidationTask(ILibraryManager libraryManager, IServerApplicationHost appHost)
+ {
+ _libraryManager = libraryManager;
+ _appHost = appHost;
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[]
+ {
+ // Every so often
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfo.TriggerInterval,
+ IntervalTicks = TimeSpan.FromDays(7).Ticks
+ }
+ };
+ }
+
+ public string Key
+ {
+ get { return "RefreshPeople"; }
+ }
+
+ /// <summary>
+ /// Returns the task to be executed
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ return _libraryManager.ValidatePeople(cancellationToken, progress);
+ }
+
+ /// <summary>
+ /// Gets the name of the task
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return "Refresh people"; }
+ }
+
+ /// <summary>
+ /// Gets the description.
+ /// </summary>
+ /// <value>The description.</value>
+ public string Description
+ {
+ get { return "Updates metadata for actors and directors in your media library."; }
+ }
+
+ /// <summary>
+ /// Gets the category.
+ /// </summary>
+ /// <value>The category.</value>
+ public string Category
+ {
+ get
+ {
+ return "Library";
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs
new file mode 100644
index 000000000..e619b6864
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs
@@ -0,0 +1,140 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Plugin Update Task
+ /// </summary>
+ public class PluginUpdateTask : IScheduledTask
+ {
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly IInstallationManager _installationManager;
+
+ private readonly IApplicationHost _appHost;
+
+ public PluginUpdateTask(ILogger logger, IInstallationManager installationManager, IApplicationHost appHost)
+ {
+ _logger = logger;
+ _installationManager = installationManager;
+ _appHost = appHost;
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // At startup
+ new TaskTriggerInfo {Type = TaskTriggerInfo.TriggerStartup},
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks}
+ };
+ }
+
+ public string Key
+ {
+ get { return "PluginUpdates"; }
+ }
+
+ /// <summary>
+ /// Update installed plugins
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ progress.Report(0);
+
+ var packagesToInstall = (await _installationManager.GetAvailablePluginUpdates(_appHost.ApplicationVersion, true, cancellationToken).ConfigureAwait(false)).ToList();
+
+ progress.Report(10);
+
+ var numComplete = 0;
+
+ // Create tasks for each one
+ var tasks = packagesToInstall.Select(i => Task.Run(async () =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ await _installationManager.InstallPackage(i, true, new Progress<double>(), cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // InstallPackage has it's own inner cancellation token, so only throw this if it's ours
+ if (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ }
+ catch (HttpException ex)
+ {
+ _logger.ErrorException("Error downloading {0}", ex, i.name);
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error updating {0}", ex, i.name);
+ }
+
+ // Update progress
+ lock (progress)
+ {
+ numComplete++;
+ double percent = numComplete;
+ percent /= packagesToInstall.Count;
+
+ progress.Report(90 * percent + 10);
+ }
+ }));
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ /// <summary>
+ /// Gets the name of the task
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return "Check for plugin updates"; }
+ }
+
+ /// <summary>
+ /// Gets the description.
+ /// </summary>
+ /// <value>The description.</value>
+ public string Description
+ {
+ get { return "Downloads and installs updates for plugins that are configured to update automatically."; }
+ }
+
+ public string Category
+ {
+ get { return "Application"; }
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/ScheduledTasks/RefreshIntrosTask.cs b/Emby.Server.Implementations/ScheduledTasks/RefreshIntrosTask.cs
new file mode 100644
index 000000000..749233fa1
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/RefreshIntrosTask.cs
@@ -0,0 +1,105 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Class RefreshIntrosTask
+ /// </summary>
+ public class RefreshIntrosTask : ILibraryPostScanTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshIntrosTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="fileSystem">The file system.</param>
+ public RefreshIntrosTask(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var files = _libraryManager.GetAllIntroFiles().ToList();
+
+ var numComplete = 0;
+
+ foreach (var file in files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ await RefreshIntro(file, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error refreshing intro {0}", ex, file);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= files.Count;
+ progress.Report(percent * 100);
+ }
+ }
+
+ /// <summary>
+ /// Refreshes the intro.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task RefreshIntro(string path, CancellationToken cancellationToken)
+ {
+ var item = _libraryManager.ResolvePath(_fileSystem.GetFileSystemInfo(path));
+
+ if (item == null)
+ {
+ _logger.Error("Intro resolver returned null for {0}", path);
+ return;
+ }
+
+ var dbItem = _libraryManager.GetItemById(item.Id);
+
+ if (dbItem != null)
+ {
+ item = dbItem;
+ }
+
+ // Force the save if it's a new item
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/RefreshMediaLibraryTask.cs
new file mode 100644
index 000000000..fb07b8e99
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/RefreshMediaLibraryTask.cs
@@ -0,0 +1,96 @@
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Library;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Class RefreshMediaLibraryTask
+ /// </summary>
+ public class RefreshMediaLibraryTask : IScheduledTask
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshMediaLibraryTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ public RefreshMediaLibraryTask(ILibraryManager libraryManager, IServerConfigurationManager config)
+ {
+ _libraryManager = libraryManager;
+ _config = config;
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(12).Ticks}
+ };
+ }
+
+ /// <summary>
+ /// Executes the internal.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ progress.Report(0);
+
+ return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return "Scan media library"; }
+ }
+
+ /// <summary>
+ /// Gets the description.
+ /// </summary>
+ /// <value>The description.</value>
+ public string Description
+ {
+ get { return "Scans your media library and refreshes metatata based on configuration."; }
+ }
+
+ /// <summary>
+ /// Gets the category.
+ /// </summary>
+ /// <value>The category.</value>
+ public string Category
+ {
+ get
+ {
+ return "Library";
+ }
+ }
+
+ public string Key
+ {
+ get { return "RefreshLibrary"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/SystemUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/SystemUpdateTask.cs
new file mode 100644
index 000000000..28fd8b68c
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/SystemUpdateTask.cs
@@ -0,0 +1,148 @@
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks
+{
+ /// <summary>
+ /// Plugin Update Task
+ /// </summary>
+ public class SystemUpdateTask : IScheduledTask
+ {
+ /// <summary>
+ /// The _app host
+ /// </summary>
+ private readonly IApplicationHost _appHost;
+
+ /// <summary>
+ /// Gets or sets the configuration manager.
+ /// </summary>
+ /// <value>The configuration manager.</value>
+ private IConfigurationManager ConfigurationManager { get; set; }
+ /// <summary>
+ /// Gets or sets the logger.
+ /// </summary>
+ /// <value>The logger.</value>
+ private ILogger Logger { get; set; }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SystemUpdateTask" /> class.
+ /// </summary>
+ /// <param name="appHost">The app host.</param>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <param name="logger">The logger.</param>
+ public SystemUpdateTask(IApplicationHost appHost, IConfigurationManager configurationManager, ILogger logger)
+ {
+ _appHost = appHost;
+ ConfigurationManager = configurationManager;
+ Logger = logger;
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // At startup
+ new TaskTriggerInfo {Type = TaskTriggerInfo.TriggerStartup},
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks}
+ };
+ }
+
+ /// <summary>
+ /// Returns the task to be executed
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ EventHandler<double> innerProgressHandler = (sender, e) => progress.Report(e * .1);
+
+ // Create a progress object for the update check
+ var innerProgress = new Progress<double>();
+ innerProgress.ProgressChanged += innerProgressHandler;
+
+ var updateInfo = await _appHost.CheckForApplicationUpdate(cancellationToken, innerProgress).ConfigureAwait(false);
+
+ // Release the event handler
+ innerProgress.ProgressChanged -= innerProgressHandler;
+
+ progress.Report(10);
+
+ if (!updateInfo.IsUpdateAvailable)
+ {
+ Logger.Debug("No application update available.");
+ progress.Report(100);
+ return;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!_appHost.CanSelfUpdate) return;
+
+ if (ConfigurationManager.CommonConfiguration.EnableAutoUpdate)
+ {
+ Logger.Info("Update Revision {0} available. Updating...", updateInfo.AvailableVersion);
+
+ innerProgressHandler = (sender, e) => progress.Report(e * .9 + .1);
+
+ innerProgress = new Progress<double>();
+ innerProgress.ProgressChanged += innerProgressHandler;
+
+ await _appHost.UpdateApplication(updateInfo.Package, cancellationToken, innerProgress).ConfigureAwait(false);
+
+ // Release the event handler
+ innerProgress.ProgressChanged -= innerProgressHandler;
+ }
+ else
+ {
+ Logger.Info("A new version of " + _appHost.Name + " is available.");
+ }
+
+ progress.Report(100);
+ }
+
+ /// <summary>
+ /// Gets the name of the task
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return "Check for application updates"; }
+ }
+
+ /// <summary>
+ /// Gets the description.
+ /// </summary>
+ /// <value>The description.</value>
+ public string Description
+ {
+ get { return "Downloads and installs application updates."; }
+ }
+
+ /// <summary>
+ /// Gets the category.
+ /// </summary>
+ /// <value>The category.</value>
+ public string Category
+ {
+ get { return "Application"; }
+ }
+
+ public string Key
+ {
+ get { return "SystemUpdateTask"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Security/AuthenticationRepository.cs b/Emby.Server.Implementations/Security/AuthenticationRepository.cs
new file mode 100644
index 000000000..a2d61873b
--- /dev/null
+++ b/Emby.Server.Implementations/Security/AuthenticationRepository.cs
@@ -0,0 +1,318 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Security
+{
+ public class AuthenticationRepository : BaseSqliteRepository, IAuthenticationRepository
+ {
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ public AuthenticationRepository(ILogger logger, IServerApplicationPaths appPaths)
+ : base(logger)
+ {
+ _appPaths = appPaths;
+ DbFilePath = Path.Combine(appPaths.DataPath, "authentication.db");
+ }
+
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists AccessTokens (Id GUID PRIMARY KEY, AccessToken TEXT NOT NULL, DeviceId TEXT, AppName TEXT, AppVersion TEXT, DeviceName TEXT, UserId TEXT, IsActive BIT, DateCreated DATETIME NOT NULL, DateRevoked DATETIME)",
+ "create index if not exists idx_AccessTokens on AccessTokens(Id)"
+ };
+
+ connection.RunQueries(queries);
+
+ connection.RunInTransaction(db =>
+ {
+ var existingColumnNames = GetColumnNames(db, "AccessTokens");
+
+ AddColumn(db, "AccessTokens", "AppVersion", "TEXT", existingColumnNames);
+
+ }, TransactionMode);
+ }
+ }
+
+ public Task Create(AuthenticationInfo info, CancellationToken cancellationToken)
+ {
+ info.Id = Guid.NewGuid().ToString("N");
+
+ return Update(info, cancellationToken);
+ }
+
+ public async Task Update(AuthenticationInfo info, CancellationToken cancellationToken)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ using (var statement = db.PrepareStatement("replace into AccessTokens (Id, AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, IsActive, DateCreated, DateRevoked) values (@Id, @AccessToken, @DeviceId, @AppName, @AppVersion, @DeviceName, @UserId, @IsActive, @DateCreated, @DateRevoked)"))
+ {
+ statement.TryBind("@Id", info.Id.ToGuidParamValue());
+ statement.TryBind("@AccessToken", info.AccessToken);
+
+ statement.TryBind("@DeviceId", info.DeviceId);
+ statement.TryBind("@AppName", info.AppName);
+ statement.TryBind("@AppVersion", info.AppVersion);
+ statement.TryBind("@DeviceName", info.DeviceName);
+ statement.TryBind("@UserId", info.UserId);
+ statement.TryBind("@IsActive", info.IsActive);
+ statement.TryBind("@DateCreated", info.DateCreated.ToDateTimeParamValue());
+
+ if (info.DateRevoked.HasValue)
+ {
+ statement.TryBind("@DateRevoked", info.DateRevoked.Value.ToDateTimeParamValue());
+ }
+ else
+ {
+ statement.TryBindNull("@DateRevoked");
+ }
+
+ statement.MoveNext();
+ }
+
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private const string BaseSelectText = "select Id, AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, IsActive, DateCreated, DateRevoked from AccessTokens";
+
+ private void BindAuthenticationQueryParams(AuthenticationInfoQuery query, IStatement statement)
+ {
+ if (!string.IsNullOrWhiteSpace(query.AccessToken))
+ {
+ statement.TryBind("@AccessToken", query.AccessToken);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.UserId))
+ {
+ statement.TryBind("@UserId", query.UserId);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.DeviceId))
+ {
+ statement.TryBind("@DeviceId", query.DeviceId);
+ }
+
+ if (query.IsActive.HasValue)
+ {
+ statement.TryBind("@IsActive", query.IsActive.Value);
+ }
+ }
+
+ public QueryResult<AuthenticationInfo> Get(AuthenticationInfoQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ var commandText = BaseSelectText;
+
+ var whereClauses = new List<string>();
+
+ var startIndex = query.StartIndex ?? 0;
+
+ if (!string.IsNullOrWhiteSpace(query.AccessToken))
+ {
+ whereClauses.Add("AccessToken=@AccessToken");
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.UserId))
+ {
+ whereClauses.Add("UserId=@UserId");
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.DeviceId))
+ {
+ whereClauses.Add("DeviceId=@DeviceId");
+ }
+
+ if (query.IsActive.HasValue)
+ {
+ whereClauses.Add("IsActive=@IsActive");
+ }
+
+ if (query.HasUser.HasValue)
+ {
+ if (query.HasUser.Value)
+ {
+ whereClauses.Add("UserId not null");
+ }
+ else
+ {
+ whereClauses.Add("UserId is null");
+ }
+ }
+
+ var whereTextWithoutPaging = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ if (startIndex > 0)
+ {
+ var pagingWhereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ whereClauses.Add(string.Format("Id NOT IN (SELECT Id FROM AccessTokens {0} ORDER BY DateCreated LIMIT {1})",
+ pagingWhereText,
+ startIndex.ToString(_usCulture)));
+ }
+
+ var whereText = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ commandText += whereText;
+
+ commandText += " ORDER BY DateCreated";
+
+ if (query.Limit.HasValue)
+ {
+ commandText += " LIMIT " + query.Limit.Value.ToString(_usCulture);
+ }
+
+ var list = new List<AuthenticationInfo>();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ return connection.RunInTransaction(db =>
+ {
+ var result = new QueryResult<AuthenticationInfo>();
+
+ var statementTexts = new List<string>();
+ statementTexts.Add(commandText);
+ statementTexts.Add("select count (Id) from AccessTokens" + whereTextWithoutPaging);
+
+ var statements = PrepareAllSafe(db, statementTexts)
+ .ToList();
+
+ using (var statement = statements[0])
+ {
+ BindAuthenticationQueryParams(query, statement);
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ list.Add(Get(row));
+ }
+
+ using (var totalCountStatement = statements[1])
+ {
+ BindAuthenticationQueryParams(query, totalCountStatement);
+
+ result.TotalRecordCount = totalCountStatement.ExecuteQuery()
+ .SelectScalarInt()
+ .First();
+ }
+ }
+
+ result.Items = list.ToArray();
+ return result;
+
+ }, ReadTransactionMode);
+ }
+ }
+ }
+
+ public AuthenticationInfo Get(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = BaseSelectText + " where Id=@Id";
+
+ using (var statement = connection.PrepareStatement(commandText))
+ {
+ statement.BindParameters["@Id"].Bind(id.ToGuidParamValue());
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ return Get(row);
+ }
+ return null;
+ }
+ }
+ }
+ }
+
+ private AuthenticationInfo Get(IReadOnlyList<IResultSetValue> reader)
+ {
+ var info = new AuthenticationInfo
+ {
+ Id = reader[0].ReadGuid().ToString("N"),
+ AccessToken = reader[1].ToString()
+ };
+
+ if (reader[2].SQLiteType != SQLiteType.Null)
+ {
+ info.DeviceId = reader[2].ToString();
+ }
+
+ if (reader[3].SQLiteType != SQLiteType.Null)
+ {
+ info.AppName = reader[3].ToString();
+ }
+
+ if (reader[4].SQLiteType != SQLiteType.Null)
+ {
+ info.AppVersion = reader[4].ToString();
+ }
+
+ if (reader[5].SQLiteType != SQLiteType.Null)
+ {
+ info.DeviceName = reader[5].ToString();
+ }
+
+ if (reader[6].SQLiteType != SQLiteType.Null)
+ {
+ info.UserId = reader[6].ToString();
+ }
+
+ info.IsActive = reader[7].ToBool();
+ info.DateCreated = reader[8].ReadDateTime();
+
+ if (reader[9].SQLiteType != SQLiteType.Null)
+ {
+ info.DateRevoked = reader[9].ReadDateTime();
+ }
+
+ return info;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Security/EncryptionManager.cs b/Emby.Server.Implementations/Security/EncryptionManager.cs
new file mode 100644
index 000000000..271b0bbdb
--- /dev/null
+++ b/Emby.Server.Implementations/Security/EncryptionManager.cs
@@ -0,0 +1,51 @@
+using MediaBrowser.Controller.Security;
+using System;
+using System.Text;
+
+namespace Emby.Server.Implementations.Security
+{
+ public class EncryptionManager : IEncryptionManager
+ {
+ /// <summary>
+ /// Encrypts the string.
+ /// </summary>
+ /// <param name="value">The value.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">value</exception>
+ public string EncryptString(string value)
+ {
+ if (value == null) throw new ArgumentNullException("value");
+
+ return EncryptStringUniversal(value);
+ }
+
+ /// <summary>
+ /// Decrypts the string.
+ /// </summary>
+ /// <param name="value">The value.</param>
+ /// <returns>System.String.</returns>
+ /// <exception cref="System.ArgumentNullException">value</exception>
+ public string DecryptString(string value)
+ {
+ if (value == null) throw new ArgumentNullException("value");
+
+ return DecryptStringUniversal(value);
+ }
+
+ private string EncryptStringUniversal(string value)
+ {
+ // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now
+
+ var bytes = Encoding.UTF8.GetBytes(value);
+ return Convert.ToBase64String(bytes);
+ }
+
+ private string DecryptStringUniversal(string value)
+ {
+ // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now
+
+ var bytes = Convert.FromBase64String(value);
+ return Encoding.UTF8.GetString(bytes, 0, bytes.Length);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Security/MBLicenseFile.cs b/Emby.Server.Implementations/Security/MBLicenseFile.cs
new file mode 100644
index 000000000..c791d6a52
--- /dev/null
+++ b/Emby.Server.Implementations/Security/MBLicenseFile.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Security
+{
+ internal class MBLicenseFile
+ {
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ public string RegKey
+ {
+ get { return _regKey; }
+ set
+ {
+ if (value != _regKey)
+ {
+ //if key is changed - clear out our saved validations
+ _updateRecords.Clear();
+ _regKey = value;
+ }
+ }
+ }
+
+ private string Filename
+ {
+ get
+ {
+ return Path.Combine(_appPaths.ConfigurationDirectoryPath, "mb.lic");
+ }
+ }
+
+ private readonly ConcurrentDictionary<Guid, FeatureRegInfo> _updateRecords = new ConcurrentDictionary<Guid, FeatureRegInfo>();
+ private readonly object _fileLock = new object();
+ private string _regKey;
+
+ public MBLicenseFile(IApplicationPaths appPaths, IFileSystem fileSystem, ICryptoProvider cryptographyProvider)
+ {
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ _cryptographyProvider = cryptographyProvider;
+
+ Load();
+ }
+
+ private void SetUpdateRecord(Guid key, FeatureRegInfo value)
+ {
+ _updateRecords.AddOrUpdate(key, value, (k, v) => value);
+ }
+
+ private Guid GetKey(string featureId)
+ {
+ return new Guid(_cryptographyProvider.ComputeMD5(Encoding.Unicode.GetBytes(featureId)));
+ }
+
+ public void AddRegCheck(string featureId, DateTime expirationDate)
+ {
+ var key = GetKey(featureId);
+ var value = new FeatureRegInfo
+ {
+ ExpirationDate = expirationDate,
+ LastChecked = DateTime.UtcNow
+ };
+
+ SetUpdateRecord(key, value);
+ Save();
+ }
+
+ public void RemoveRegCheck(string featureId)
+ {
+ var key = GetKey(featureId);
+ FeatureRegInfo val;
+
+ _updateRecords.TryRemove(key, out val);
+
+ Save();
+ }
+
+ public FeatureRegInfo GetRegInfo(string featureId)
+ {
+ var key = GetKey(featureId);
+ FeatureRegInfo info = null;
+ _updateRecords.TryGetValue(key, out info);
+
+ if (info == null)
+ {
+ return null;
+ }
+
+ // guard agains people just putting a large number in the file
+ return info.LastChecked < DateTime.UtcNow ? info : null;
+ }
+
+ private void Load()
+ {
+ string[] contents = null;
+ var licenseFile = Filename;
+ lock (_fileLock)
+ {
+ try
+ {
+ contents = _fileSystem.ReadAllLines(licenseFile);
+ }
+ catch (FileNotFoundException)
+ {
+ lock (_fileLock)
+ {
+ _fileSystem.WriteAllBytes(licenseFile, new byte[] { });
+ }
+ }
+ catch (IOException)
+ {
+ lock (_fileLock)
+ {
+ _fileSystem.WriteAllBytes(licenseFile, new byte[] { });
+ }
+ }
+ }
+ if (contents != null && contents.Length > 0)
+ {
+ //first line is reg key
+ RegKey = contents[0];
+
+ //next is legacy key
+ if (contents.Length > 1)
+ {
+ // Don't need this anymore
+ }
+
+ //the rest of the lines should be pairs of features and timestamps
+ for (var i = 2; i < contents.Length; i = i + 2)
+ {
+ var line = contents[i];
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ Guid feat;
+ if (Guid.TryParse(line, out feat))
+ {
+ var lineParts = contents[i + 1].Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+
+ long ticks;
+ if (long.TryParse(lineParts[0], out ticks))
+ {
+ var info = new FeatureRegInfo
+ {
+ LastChecked = new DateTime(ticks)
+ };
+
+ if (lineParts.Length > 1 && long.TryParse(lineParts[1], out ticks))
+ {
+ info.ExpirationDate = new DateTime(ticks);
+ }
+
+ SetUpdateRecord(feat, info);
+ }
+ }
+ }
+ }
+ }
+
+ public void Save()
+ {
+ //build our array
+ var lines = new List<string>
+ {
+ RegKey,
+
+ // Legacy key
+ string.Empty
+ };
+
+ foreach (var pair in _updateRecords
+ .ToList())
+ {
+ lines.Add(pair.Key.ToString());
+
+ var dateLine = pair.Value.LastChecked.Ticks.ToString(CultureInfo.InvariantCulture) + "|" +
+ pair.Value.ExpirationDate.Ticks.ToString(CultureInfo.InvariantCulture);
+
+ lines.Add(dateLine);
+ }
+
+ var licenseFile = Filename;
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(licenseFile));
+ lock (_fileLock)
+ {
+ _fileSystem.WriteAllLines(licenseFile, lines);
+ }
+ }
+ }
+
+ internal class FeatureRegInfo
+ {
+ public DateTime ExpirationDate { get; set; }
+ public DateTime LastChecked { get; set; }
+
+ public FeatureRegInfo()
+ {
+ ExpirationDate = DateTime.MinValue;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Security/PluginSecurityManager.cs b/Emby.Server.Implementations/Security/PluginSecurityManager.cs
new file mode 100644
index 000000000..f21259137
--- /dev/null
+++ b/Emby.Server.Implementations/Security/PluginSecurityManager.cs
@@ -0,0 +1,355 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+
+namespace Emby.Server.Implementations.Security
+{
+ /// <summary>
+ /// Class PluginSecurityManager
+ /// </summary>
+ public class PluginSecurityManager : ISecurityManager
+ {
+ private const string MBValidateUrl = "https://mb3admin.com/admin/service/registration/validate";
+ private const string AppstoreRegUrl = /*MbAdmin.HttpsUrl*/ "https://mb3admin.com/admin/service/appstore/register";
+
+ /// <summary>
+ /// The _is MB supporter
+ /// </summary>
+ private bool? _isMbSupporter;
+ /// <summary>
+ /// The _is MB supporter initialized
+ /// </summary>
+ private bool _isMbSupporterInitialized;
+ /// <summary>
+ /// The _is MB supporter sync lock
+ /// </summary>
+ private object _isMbSupporterSyncLock = new object();
+
+ /// <summary>
+ /// Gets a value indicating whether this instance is MB supporter.
+ /// </summary>
+ /// <value><c>true</c> if this instance is MB supporter; otherwise, <c>false</c>.</value>
+ public bool IsMBSupporter
+ {
+ get
+ {
+ LazyInitializer.EnsureInitialized(ref _isMbSupporter, ref _isMbSupporterInitialized, ref _isMbSupporterSyncLock, () => GetSupporterRegistrationStatus().Result.IsRegistered);
+ return _isMbSupporter.Value;
+ }
+ }
+
+ private MBLicenseFile _licenseFile;
+ private MBLicenseFile LicenseFile
+ {
+ get { return _licenseFile ?? (_licenseFile = new MBLicenseFile(_appPaths, _fileSystem, _cryptographyProvider)); }
+ }
+
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IFileSystem _fileSystem;
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ private IEnumerable<IRequiresRegistration> _registeredEntities;
+ protected IEnumerable<IRequiresRegistration> RegisteredEntities
+ {
+ get
+ {
+ return _registeredEntities ?? (_registeredEntities = _appHost.GetExports<IRequiresRegistration>());
+ }
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginSecurityManager" /> class.
+ /// </summary>
+ public PluginSecurityManager(IServerApplicationHost appHost, IHttpClient httpClient, IJsonSerializer jsonSerializer,
+ IApplicationPaths appPaths, ILogManager logManager, IFileSystem fileSystem, ICryptoProvider cryptographyProvider)
+ {
+ if (httpClient == null)
+ {
+ throw new ArgumentNullException("httpClient");
+ }
+
+ _appHost = appHost;
+ _httpClient = httpClient;
+ _jsonSerializer = jsonSerializer;
+ _appPaths = appPaths;
+ _fileSystem = fileSystem;
+ _cryptographyProvider = cryptographyProvider;
+ _logger = logManager.GetLogger("SecurityManager");
+ }
+
+ /// <summary>
+ /// Load all registration info for all entities that require registration
+ /// </summary>
+ /// <returns></returns>
+ public async Task LoadAllRegistrationInfo()
+ {
+ var tasks = new List<Task>();
+
+ ResetSupporterInfo();
+ tasks.AddRange(RegisteredEntities.Select(i => i.LoadRegistrationInfoAsync()));
+ await Task.WhenAll(tasks);
+ }
+
+ /// <summary>
+ /// Gets the registration status.
+ /// This overload supports existing plug-ins.
+ /// </summary>
+ /// <param name="feature">The feature.</param>
+ /// <param name="mb2Equivalent">The MB2 equivalent.</param>
+ /// <returns>Task{MBRegistrationRecord}.</returns>
+ public Task<MBRegistrationRecord> GetRegistrationStatus(string feature, string mb2Equivalent = null)
+ {
+ return GetRegistrationStatusInternal(feature, mb2Equivalent);
+ }
+
+ /// <summary>
+ /// Gets the registration status.
+ /// </summary>
+ /// <param name="feature">The feature.</param>
+ /// <param name="mb2Equivalent">The MB2 equivalent.</param>
+ /// <param name="version">The version of this feature</param>
+ /// <returns>Task{MBRegistrationRecord}.</returns>
+ public Task<MBRegistrationRecord> GetRegistrationStatus(string feature, string mb2Equivalent, string version)
+ {
+ return GetRegistrationStatusInternal(feature, mb2Equivalent, version);
+ }
+
+ private Task<MBRegistrationRecord> GetSupporterRegistrationStatus()
+ {
+ return GetRegistrationStatusInternal("MBSupporter", null, _appHost.ApplicationVersion.ToString());
+ }
+
+ /// <summary>
+ /// Gets or sets the supporter key.
+ /// </summary>
+ /// <value>The supporter key.</value>
+ public string SupporterKey
+ {
+ get
+ {
+ return LicenseFile.RegKey;
+ }
+ set
+ {
+ var newValue = value;
+ if (newValue != null)
+ {
+ newValue = newValue.Trim();
+ }
+
+ if (newValue != LicenseFile.RegKey)
+ {
+ LicenseFile.RegKey = newValue;
+ LicenseFile.Save();
+
+ // re-load registration info
+ Task.Run(() => LoadAllRegistrationInfo());
+ }
+ }
+ }
+
+ /// <summary>
+ /// Register an app store sale with our back-end. It will validate the transaction with the store
+ /// and then register the proper feature and then fill in the supporter key on success.
+ /// </summary>
+ /// <param name="parameters">Json parameters to send to admin server</param>
+ public async Task RegisterAppStoreSale(string parameters)
+ {
+ var options = new HttpRequestOptions()
+ {
+ Url = AppstoreRegUrl,
+ CancellationToken = CancellationToken.None,
+ BufferContent = false
+ };
+ options.RequestHeaders.Add("X-Emby-Token", _appHost.SystemId);
+ options.RequestContent = parameters;
+ options.RequestContentType = "application/json";
+
+ try
+ {
+ using (var response = await _httpClient.Post(options).ConfigureAwait(false))
+ {
+ var reg = _jsonSerializer.DeserializeFromStream<RegRecord>(response.Content);
+
+ if (reg == null)
+ {
+ var msg = "Result from appstore registration was null.";
+ _logger.Error(msg);
+ throw new ArgumentException(msg);
+ }
+ if (!String.IsNullOrEmpty(reg.key))
+ {
+ SupporterKey = reg.key;
+ }
+ }
+
+ }
+ catch (ArgumentException)
+ {
+ SaveAppStoreInfo(parameters);
+ throw;
+ }
+ catch (HttpException e)
+ {
+ _logger.ErrorException("Error registering appstore purchase {0}", e, parameters ?? "NO PARMS SENT");
+
+ if (e.StatusCode.HasValue && e.StatusCode.Value == HttpStatusCode.PaymentRequired)
+ {
+ throw new PaymentRequiredException();
+ }
+ throw new Exception("Error registering store sale");
+ }
+ catch (Exception e)
+ {
+ _logger.ErrorException("Error registering appstore purchase {0}", e, parameters ?? "NO PARMS SENT");
+ SaveAppStoreInfo(parameters);
+ //TODO - could create a re-try routine on start-up if this file is there. For now we can handle manually.
+ throw new Exception("Error registering store sale");
+ }
+
+ }
+
+ private void SaveAppStoreInfo(string info)
+ {
+ // Save all transaction information to a file
+
+ try
+ {
+ _fileSystem.WriteAllText(Path.Combine(_appPaths.ProgramDataPath, "apptrans-error.txt"), info);
+ }
+ catch (IOException)
+ {
+
+ }
+ }
+
+ private async Task<MBRegistrationRecord> GetRegistrationStatusInternal(string feature,
+ string mb2Equivalent = null,
+ string version = null)
+ {
+ var regInfo = LicenseFile.GetRegInfo(feature);
+ var lastChecked = regInfo == null ? DateTime.MinValue : regInfo.LastChecked;
+ var expDate = regInfo == null ? DateTime.MinValue : regInfo.ExpirationDate;
+
+ var maxCacheDays = 14;
+ var nextCheckDate = new [] { expDate, lastChecked.AddDays(maxCacheDays) }.Min();
+
+ if (nextCheckDate > DateTime.UtcNow.AddDays(maxCacheDays))
+ {
+ nextCheckDate = DateTime.MinValue;
+ }
+
+ //check the reg file first to alleviate strain on the MB admin server - must actually check in every 30 days tho
+ var reg = new RegRecord
+ {
+ // Cache the result for up to a week
+ registered = regInfo != null && nextCheckDate >= DateTime.UtcNow && expDate >= DateTime.UtcNow,
+ expDate = expDate
+ };
+
+ var success = reg.registered;
+
+ if (!(lastChecked > DateTime.UtcNow.AddDays(-1)) || !reg.registered)
+ {
+ var data = new Dictionary<string, string>
+ {
+ { "feature", feature },
+ { "key", SupporterKey },
+ { "mac", _appHost.SystemId },
+ { "systemid", _appHost.SystemId },
+ { "mb2equiv", mb2Equivalent },
+ { "ver", version },
+ { "platform", _appHost.OperatingSystemDisplayName },
+ { "isservice", _appHost.IsRunningAsService.ToString().ToLower() }
+ };
+
+ try
+ {
+ var options = new HttpRequestOptions
+ {
+ Url = MBValidateUrl,
+
+ // Seeing block length errors
+ EnableHttpCompression = false,
+ BufferContent = false
+ };
+
+ options.SetPostData(data);
+
+ using (var json = (await _httpClient.Post(options).ConfigureAwait(false)).Content)
+ {
+ reg = _jsonSerializer.DeserializeFromStream<RegRecord>(json);
+ success = true;
+ }
+
+ if (reg.registered)
+ {
+ LicenseFile.AddRegCheck(feature, reg.expDate);
+ }
+ else
+ {
+ LicenseFile.RemoveRegCheck(feature);
+ }
+
+ }
+ catch (Exception e)
+ {
+ _logger.ErrorException("Error checking registration status of {0}", e, feature);
+ }
+ }
+
+ var record = new MBRegistrationRecord
+ {
+ IsRegistered = reg.registered,
+ ExpirationDate = reg.expDate,
+ RegChecked = true,
+ RegError = !success
+ };
+
+ record.TrialVersion = IsInTrial(reg.expDate, record.RegChecked, record.IsRegistered);
+ record.IsValid = !record.RegChecked || record.IsRegistered || record.TrialVersion;
+
+ return record;
+ }
+
+ private bool IsInTrial(DateTime expirationDate, bool regChecked, bool isRegistered)
+ {
+ //don't set this until we've successfully obtained exp date
+ if (!regChecked)
+ {
+ return false;
+ }
+
+ var isInTrial = expirationDate > DateTime.UtcNow;
+
+ return isInTrial && !isRegistered;
+ }
+
+ /// <summary>
+ /// Resets the supporter info.
+ /// </summary>
+ private void ResetSupporterInfo()
+ {
+ _isMbSupporter = null;
+ _isMbSupporterInitialized = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Security/RegRecord.cs b/Emby.Server.Implementations/Security/RegRecord.cs
new file mode 100644
index 000000000..d484085d3
--- /dev/null
+++ b/Emby.Server.Implementations/Security/RegRecord.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Emby.Server.Implementations.Security
+{
+ class RegRecord
+ {
+ public string featId { get; set; }
+ public bool registered { get; set; }
+ public DateTime expDate { get; set; }
+ public string key { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/ServerManager/ServerManager.cs b/Emby.Server.Implementations/ServerManager/ServerManager.cs
new file mode 100644
index 000000000..f7e4c0ce2
--- /dev/null
+++ b/Emby.Server.Implementations/ServerManager/ServerManager.cs
@@ -0,0 +1,353 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.Text;
+
+namespace Emby.Server.Implementations.ServerManager
+{
+ /// <summary>
+ /// Manages the Http Server, Udp Server and WebSocket connections
+ /// </summary>
+ public class ServerManager : IServerManager
+ {
+ /// <summary>
+ /// Both the Ui and server will have a built-in HttpServer.
+ /// People will inevitably want remote control apps so it's needed in the Ui too.
+ /// </summary>
+ /// <value>The HTTP server.</value>
+ private IHttpServer HttpServer { get; set; }
+
+ /// <summary>
+ /// Gets or sets the json serializer.
+ /// </summary>
+ /// <value>The json serializer.</value>
+ private readonly IJsonSerializer _jsonSerializer;
+
+ /// <summary>
+ /// The web socket connections
+ /// </summary>
+ private readonly List<IWebSocketConnection> _webSocketConnections = new List<IWebSocketConnection>();
+ /// <summary>
+ /// Gets the web socket connections.
+ /// </summary>
+ /// <value>The web socket connections.</value>
+ public IEnumerable<IWebSocketConnection> WebSocketConnections
+ {
+ get { return _webSocketConnections; }
+ }
+
+ public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// The _application host
+ /// </summary>
+ private readonly IServerApplicationHost _applicationHost;
+
+ /// <summary>
+ /// Gets or sets the configuration manager.
+ /// </summary>
+ /// <value>The configuration manager.</value>
+ private IServerConfigurationManager ConfigurationManager { get; set; }
+
+ /// <summary>
+ /// Gets the web socket listeners.
+ /// </summary>
+ /// <value>The web socket listeners.</value>
+ private readonly List<IWebSocketListener> _webSocketListeners = new List<IWebSocketListener>();
+
+ private bool _disposed;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+ private readonly ITextEncoding _textEncoding;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ServerManager" /> class.
+ /// </summary>
+ /// <param name="applicationHost">The application host.</param>
+ /// <param name="jsonSerializer">The json serializer.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <exception cref="System.ArgumentNullException">applicationHost</exception>
+ public ServerManager(IServerApplicationHost applicationHost, IJsonSerializer jsonSerializer, ILogger logger, IServerConfigurationManager configurationManager, IMemoryStreamFactory memoryStreamProvider, ITextEncoding textEncoding)
+ {
+ if (applicationHost == null)
+ {
+ throw new ArgumentNullException("applicationHost");
+ }
+ if (jsonSerializer == null)
+ {
+ throw new ArgumentNullException("jsonSerializer");
+ }
+ if (logger == null)
+ {
+ throw new ArgumentNullException("logger");
+ }
+
+ _logger = logger;
+ _jsonSerializer = jsonSerializer;
+ _applicationHost = applicationHost;
+ ConfigurationManager = configurationManager;
+ _memoryStreamProvider = memoryStreamProvider;
+ _textEncoding = textEncoding;
+ }
+
+ /// <summary>
+ /// Starts this instance.
+ /// </summary>
+ public void Start(IEnumerable<string> urlPrefixes)
+ {
+ ReloadHttpServer(urlPrefixes);
+ }
+
+ /// <summary>
+ /// Restarts the Http Server, or starts it if not currently running
+ /// </summary>
+ private void ReloadHttpServer(IEnumerable<string> urlPrefixes)
+ {
+ _logger.Info("Loading Http Server");
+
+ try
+ {
+ HttpServer = _applicationHost.Resolve<IHttpServer>();
+ HttpServer.StartServer(urlPrefixes);
+ }
+ catch (Exception ex)
+ {
+ var msg = string.Equals(ex.GetType().Name, "SocketException", StringComparison.OrdinalIgnoreCase)
+ ? "The http server is unable to start due to a Socket error. This can occasionally happen when the operating system takes longer than usual to release the IP bindings from the previous session. This can take up to five minutes. Please try waiting or rebooting the system."
+ : "Error starting Http Server";
+
+ _logger.ErrorException(msg, ex);
+
+ throw;
+ }
+
+ HttpServer.WebSocketConnected += HttpServer_WebSocketConnected;
+ }
+
+ /// <summary>
+ /// Handles the WebSocketConnected event of the HttpServer control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="WebSocketConnectEventArgs" /> instance containing the event data.</param>
+ void HttpServer_WebSocketConnected(object sender, WebSocketConnectEventArgs e)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var connection = new WebSocketConnection(e.WebSocket, e.Endpoint, _jsonSerializer, _logger, _memoryStreamProvider, _textEncoding)
+ {
+ OnReceive = ProcessWebSocketMessageReceived,
+ Url = e.Url,
+ QueryString = e.QueryString ?? new QueryParamCollection()
+ };
+
+ _webSocketConnections.Add(connection);
+
+ if (WebSocketConnected != null)
+ {
+ EventHelper.FireEventIfNotNull(WebSocketConnected, this, new GenericEventArgs<IWebSocketConnection> (connection), _logger);
+ }
+ }
+
+ /// <summary>
+ /// Processes the web socket message received.
+ /// </summary>
+ /// <param name="result">The result.</param>
+ private async void ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ //_logger.Debug("Websocket message received: {0}", result.MessageType);
+
+ var tasks = _webSocketListeners.Select(i => Task.Run(async () =>
+ {
+ try
+ {
+ await i.ProcessMessage(result).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("{0} failed processing WebSocket message {1}", ex, i.GetType().Name, result.MessageType ?? string.Empty);
+ }
+ }));
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Sends a message to all clients currently connected via a web socket
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="messageType">Type of the message.</param>
+ /// <param name="data">The data.</param>
+ /// <returns>Task.</returns>
+ public void SendWebSocketMessage<T>(string messageType, T data)
+ {
+ SendWebSocketMessage(messageType, () => data);
+ }
+
+ /// <summary>
+ /// Sends a message to all clients currently connected via a web socket
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="messageType">Type of the message.</param>
+ /// <param name="dataFunction">The function that generates the data to send, if there are any connected clients</param>
+ public void SendWebSocketMessage<T>(string messageType, Func<T> dataFunction)
+ {
+ SendWebSocketMessageAsync(messageType, dataFunction, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Sends a message to all clients currently connected via a web socket
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="messageType">Type of the message.</param>
+ /// <param name="dataFunction">The function that generates the data to send, if there are any connected clients</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">messageType</exception>
+ public Task SendWebSocketMessageAsync<T>(string messageType, Func<T> dataFunction, CancellationToken cancellationToken)
+ {
+ return SendWebSocketMessageAsync(messageType, dataFunction, _webSocketConnections, cancellationToken);
+ }
+
+ /// <summary>
+ /// Sends the web socket message async.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="messageType">Type of the message.</param>
+ /// <param name="dataFunction">The data function.</param>
+ /// <param name="connections">The connections.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">messageType
+ /// or
+ /// dataFunction
+ /// or
+ /// cancellationToken</exception>
+ private async Task SendWebSocketMessageAsync<T>(string messageType, Func<T> dataFunction, IEnumerable<IWebSocketConnection> connections, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(messageType))
+ {
+ throw new ArgumentNullException("messageType");
+ }
+
+ if (dataFunction == null)
+ {
+ throw new ArgumentNullException("dataFunction");
+ }
+
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(GetType().Name);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var connectionsList = connections.Where(s => s.State == WebSocketState.Open).ToList();
+
+ if (connectionsList.Count > 0)
+ {
+ _logger.Info("Sending web socket message {0}", messageType);
+
+ var message = new WebSocketMessage<T> { MessageType = messageType, Data = dataFunction() };
+ var json = _jsonSerializer.SerializeToString(message);
+
+ var tasks = connectionsList.Select(s => Task.Run(() =>
+ {
+ try
+ {
+ s.SendAsync(json, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending web socket message {0} to {1}", ex, messageType, s.RemoteEndPoint);
+ }
+
+ }, cancellationToken));
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+ }
+
+ /// <summary>
+ /// Disposes the current HttpServer
+ /// </summary>
+ private void DisposeHttpServer()
+ {
+ foreach (var socket in _webSocketConnections)
+ {
+ // Dispose the connection
+ socket.Dispose();
+ }
+
+ _webSocketConnections.Clear();
+
+ if (HttpServer != null)
+ {
+ HttpServer.WebSocketConnected -= HttpServer_WebSocketConnected;
+ HttpServer.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ _disposed = true;
+
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ DisposeHttpServer();
+ }
+ }
+
+ /// <summary>
+ /// Adds the web socket listeners.
+ /// </summary>
+ /// <param name="listeners">The listeners.</param>
+ public void AddWebSocketListeners(IEnumerable<IWebSocketListener> listeners)
+ {
+ _webSocketListeners.AddRange(listeners);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ServerManager/WebSocketConnection.cs b/Emby.Server.Implementations/ServerManager/WebSocketConnection.cs
new file mode 100644
index 000000000..4608a13e6
--- /dev/null
+++ b/Emby.Server.Implementations/ServerManager/WebSocketConnection.cs
@@ -0,0 +1,295 @@
+using System.Text;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Specialized;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.Text;
+using UniversalDetector;
+
+namespace Emby.Server.Implementations.ServerManager
+{
+ /// <summary>
+ /// Class WebSocketConnection
+ /// </summary>
+ public class WebSocketConnection : IWebSocketConnection
+ {
+ public event EventHandler<EventArgs> Closed;
+
+ /// <summary>
+ /// The _socket
+ /// </summary>
+ private readonly IWebSocket _socket;
+
+ /// <summary>
+ /// The _remote end point
+ /// </summary>
+ public string RemoteEndPoint { get; private set; }
+
+ /// <summary>
+ /// The _cancellation token source
+ /// </summary>
+ private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
+
+ /// <summary>
+ /// The logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// The _json serializer
+ /// </summary>
+ private readonly IJsonSerializer _jsonSerializer;
+
+ /// <summary>
+ /// Gets or sets the receive action.
+ /// </summary>
+ /// <value>The receive action.</value>
+ public Action<WebSocketMessageInfo> OnReceive { get; set; }
+
+ /// <summary>
+ /// Gets the last activity date.
+ /// </summary>
+ /// <value>The last activity date.</value>
+ public DateTime LastActivityDate { get; private set; }
+
+ /// <summary>
+ /// Gets the id.
+ /// </summary>
+ /// <value>The id.</value>
+ public Guid Id { get; private set; }
+
+ /// <summary>
+ /// Gets or sets the URL.
+ /// </summary>
+ /// <value>The URL.</value>
+ public string Url { get; set; }
+ /// <summary>
+ /// Gets or sets the query string.
+ /// </summary>
+ /// <value>The query string.</value>
+ public QueryParamCollection QueryString { get; set; }
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+ private readonly ITextEncoding _textEncoding;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="WebSocketConnection" /> class.
+ /// </summary>
+ /// <param name="socket">The socket.</param>
+ /// <param name="remoteEndPoint">The remote end point.</param>
+ /// <param name="jsonSerializer">The json serializer.</param>
+ /// <param name="logger">The logger.</param>
+ /// <exception cref="System.ArgumentNullException">socket</exception>
+ public WebSocketConnection(IWebSocket socket, string remoteEndPoint, IJsonSerializer jsonSerializer, ILogger logger, IMemoryStreamFactory memoryStreamProvider, ITextEncoding textEncoding)
+ {
+ if (socket == null)
+ {
+ throw new ArgumentNullException("socket");
+ }
+ if (string.IsNullOrEmpty(remoteEndPoint))
+ {
+ throw new ArgumentNullException("remoteEndPoint");
+ }
+ if (jsonSerializer == null)
+ {
+ throw new ArgumentNullException("jsonSerializer");
+ }
+ if (logger == null)
+ {
+ throw new ArgumentNullException("logger");
+ }
+
+ Id = Guid.NewGuid();
+ _jsonSerializer = jsonSerializer;
+ _socket = socket;
+ _socket.OnReceiveBytes = OnReceiveInternal;
+ _socket.OnReceive = OnReceiveInternal;
+ RemoteEndPoint = remoteEndPoint;
+ _logger = logger;
+ _memoryStreamProvider = memoryStreamProvider;
+ _textEncoding = textEncoding;
+
+ socket.Closed += socket_Closed;
+ }
+
+ void socket_Closed(object sender, EventArgs e)
+ {
+ EventHelper.FireEventIfNotNull(Closed, this, EventArgs.Empty, _logger);
+ }
+
+ /// <summary>
+ /// Called when [receive].
+ /// </summary>
+ /// <param name="bytes">The bytes.</param>
+ private void OnReceiveInternal(byte[] bytes)
+ {
+ LastActivityDate = DateTime.UtcNow;
+
+ if (OnReceive == null)
+ {
+ return;
+ }
+ var charset = DetectCharset(bytes);
+
+ if (string.Equals(charset, "utf-8", StringComparison.OrdinalIgnoreCase))
+ {
+ OnReceiveInternal(Encoding.UTF8.GetString(bytes, 0, bytes.Length));
+ }
+ else
+ {
+ OnReceiveInternal(_textEncoding.GetASCIIEncoding().GetString(bytes, 0, bytes.Length));
+ }
+ }
+ private string DetectCharset(byte[] bytes)
+ {
+ try
+ {
+ using (var ms = _memoryStreamProvider.CreateNew(bytes))
+ {
+ var detector = new CharsetDetector();
+ detector.Feed(ms);
+ detector.DataEnd();
+
+ var charset = detector.Charset;
+
+ if (!string.IsNullOrWhiteSpace(charset))
+ {
+ //_logger.Debug("UniversalDetector detected charset {0}", charset);
+ }
+
+ return charset;
+ }
+ }
+ catch (IOException ex)
+ {
+ _logger.ErrorException("Error attempting to determine web socket message charset", ex);
+ }
+
+ return null;
+ }
+
+ private void OnReceiveInternal(string message)
+ {
+ LastActivityDate = DateTime.UtcNow;
+
+ if (!message.StartsWith("{", StringComparison.OrdinalIgnoreCase))
+ {
+ // This info is useful sometimes but also clogs up the log
+ //_logger.Error("Received web socket message that is not a json structure: " + message);
+ return;
+ }
+
+ if (OnReceive == null)
+ {
+ return;
+ }
+
+ try
+ {
+ var stub = (WebSocketMessage<object>)_jsonSerializer.DeserializeFromString(message, typeof(WebSocketMessage<object>));
+
+ var info = new WebSocketMessageInfo
+ {
+ MessageType = stub.MessageType,
+ Data = stub.Data == null ? null : stub.Data.ToString(),
+ Connection = this
+ };
+
+ OnReceive(info);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error processing web socket message", ex);
+ }
+ }
+
+ /// <summary>
+ /// Sends a message asynchronously.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="message">The message.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">message</exception>
+ public Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken)
+ {
+ if (message == null)
+ {
+ throw new ArgumentNullException("message");
+ }
+
+ var json = _jsonSerializer.SerializeToString(message);
+
+ return SendAsync(json, cancellationToken);
+ }
+
+ /// <summary>
+ /// Sends a message asynchronously.
+ /// </summary>
+ /// <param name="buffer">The buffer.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendAsync(byte[] buffer, CancellationToken cancellationToken)
+ {
+ if (buffer == null)
+ {
+ throw new ArgumentNullException("buffer");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return _socket.SendAsync(buffer, true, cancellationToken);
+ }
+
+ public Task SendAsync(string text, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ throw new ArgumentNullException("text");
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return _socket.SendAsync(text, true, cancellationToken);
+ }
+
+ /// <summary>
+ /// Gets the state.
+ /// </summary>
+ /// <value>The state.</value>
+ public WebSocketState State
+ {
+ get { return _socket.State; }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ _cancellationTokenSource.Dispose();
+ _socket.Dispose();
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Session/HttpSessionController.cs b/Emby.Server.Implementations/Session/HttpSessionController.cs
new file mode 100644
index 000000000..cea5d9b40
--- /dev/null
+++ b/Emby.Server.Implementations/Session/HttpSessionController.cs
@@ -0,0 +1,186 @@
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.System;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Session
+{
+ public class HttpSessionController : ISessionController, IDisposable
+ {
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _json;
+ private readonly ISessionManager _sessionManager;
+
+ public SessionInfo Session { get; private set; }
+
+ private readonly string _postUrl;
+
+ public HttpSessionController(IHttpClient httpClient,
+ IJsonSerializer json,
+ SessionInfo session,
+ string postUrl, ISessionManager sessionManager)
+ {
+ _httpClient = httpClient;
+ _json = json;
+ Session = session;
+ _postUrl = postUrl;
+ _sessionManager = sessionManager;
+ }
+
+ public void OnActivity()
+ {
+ }
+
+ private string PostUrl
+ {
+ get
+ {
+ return string.Format("http://{0}{1}", Session.RemoteEndPoint, _postUrl);
+ }
+ }
+
+ public bool IsSessionActive
+ {
+ get
+ {
+ return (DateTime.UtcNow - Session.LastActivityDate).TotalMinutes <= 10;
+ }
+ }
+
+ public bool SupportsMediaControl
+ {
+ get { return true; }
+ }
+
+ private Task SendMessage(string name, CancellationToken cancellationToken)
+ {
+ return SendMessage(name, new Dictionary<string, string>(), cancellationToken);
+ }
+
+ private async Task SendMessage(string name,
+ Dictionary<string, string> args,
+ CancellationToken cancellationToken)
+ {
+ var url = PostUrl + "/" + name + ToQueryString(args);
+
+ await _httpClient.Post(new HttpRequestOptions
+ {
+ Url = url,
+ CancellationToken = cancellationToken,
+ BufferContent = false
+
+ }).ConfigureAwait(false);
+ }
+
+ public Task SendSessionEndedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task SendPlaybackStartNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task SendPlaybackStoppedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
+ {
+ var dict = new Dictionary<string, string>();
+
+ dict["ItemIds"] = string.Join(",", command.ItemIds);
+
+ if (command.StartPositionTicks.HasValue)
+ {
+ dict["StartPositionTicks"] = command.StartPositionTicks.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return SendMessage(command.PlayCommand.ToString(), dict, cancellationToken);
+ }
+
+ public Task SendPlaystateCommand(PlaystateRequest command, CancellationToken cancellationToken)
+ {
+ var args = new Dictionary<string, string>();
+
+ if (command.Command == PlaystateCommand.Seek)
+ {
+ if (!command.SeekPositionTicks.HasValue)
+ {
+ throw new ArgumentException("SeekPositionTicks cannot be null");
+ }
+
+ args["SeekPositionTicks"] = command.SeekPositionTicks.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return SendMessage(command.Command.ToString(), args, cancellationToken);
+ }
+
+ public Task SendLibraryUpdateInfo(LibraryUpdateInfo info, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task SendRestartRequiredNotification(SystemInfo info, CancellationToken cancellationToken)
+ {
+ return SendMessage("RestartRequired", cancellationToken);
+ }
+
+ public Task SendUserDataChangeInfo(UserDataChangeInfo info, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task SendServerShutdownNotification(CancellationToken cancellationToken)
+ {
+ return SendMessage("ServerShuttingDown", cancellationToken);
+ }
+
+ public Task SendServerRestartNotification(CancellationToken cancellationToken)
+ {
+ return SendMessage("ServerRestarting", cancellationToken);
+ }
+
+ public Task SendGeneralCommand(GeneralCommand command, CancellationToken cancellationToken)
+ {
+ return SendMessage(command.Name, command.Arguments, cancellationToken);
+ }
+
+ public Task SendMessage<T>(string name, T data, CancellationToken cancellationToken)
+ {
+ // Not supported or needed right now
+ return Task.FromResult(true);
+ }
+
+ private string ToQueryString(Dictionary<string, string> nvc)
+ {
+ var array = (from item in nvc
+ select string.Format("{0}={1}", WebUtility.UrlEncode(item.Key), WebUtility.UrlEncode(item.Value)))
+ .ToArray();
+
+ var args = string.Join("&", array);
+
+ if (string.IsNullOrEmpty(args))
+ {
+ return args;
+ }
+
+ return "?" + args;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
new file mode 100644
index 000000000..a20fb67b2
--- /dev/null
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -0,0 +1,1961 @@
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Users;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Threading;
+
+namespace Emby.Server.Implementations.Session
+{
+ /// <summary>
+ /// Class SessionManager
+ /// </summary>
+ public class SessionManager : ISessionManager
+ {
+ /// <summary>
+ /// The _user data repository
+ /// </summary>
+ private readonly IUserDataManager _userDataManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IMusicManager _musicManager;
+ private readonly IDtoService _dtoService;
+ private readonly IImageProcessor _imageProcessor;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly IServerApplicationHost _appHost;
+
+ private readonly IAuthenticationRepository _authRepo;
+ private readonly IDeviceManager _deviceManager;
+ private readonly ITimerFactory _timerFactory;
+
+ /// <summary>
+ /// The _active connections
+ /// </summary>
+ private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections =
+ new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
+
+ public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed;
+
+ public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationSucceeded;
+
+ /// <summary>
+ /// Occurs when [playback start].
+ /// </summary>
+ public event EventHandler<PlaybackProgressEventArgs> PlaybackStart;
+ /// <summary>
+ /// Occurs when [playback progress].
+ /// </summary>
+ public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress;
+ /// <summary>
+ /// Occurs when [playback stopped].
+ /// </summary>
+ public event EventHandler<PlaybackStopEventArgs> PlaybackStopped;
+
+ public event EventHandler<SessionEventArgs> SessionStarted;
+ public event EventHandler<SessionEventArgs> CapabilitiesChanged;
+ public event EventHandler<SessionEventArgs> SessionEnded;
+ public event EventHandler<SessionEventArgs> SessionActivity;
+
+ private IEnumerable<ISessionControllerFactory> _sessionFactories = new List<ISessionControllerFactory>();
+
+ private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
+
+ public SessionManager(IUserDataManager userDataManager, ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IMusicManager musicManager, IDtoService dtoService, IImageProcessor imageProcessor, IJsonSerializer jsonSerializer, IServerApplicationHost appHost, IHttpClient httpClient, IAuthenticationRepository authRepo, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, ITimerFactory timerFactory)
+ {
+ _userDataManager = userDataManager;
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _musicManager = musicManager;
+ _dtoService = dtoService;
+ _imageProcessor = imageProcessor;
+ _jsonSerializer = jsonSerializer;
+ _appHost = appHost;
+ _httpClient = httpClient;
+ _authRepo = authRepo;
+ _deviceManager = deviceManager;
+ _mediaSourceManager = mediaSourceManager;
+ _timerFactory = timerFactory;
+
+ _deviceManager.DeviceOptionsUpdated += _deviceManager_DeviceOptionsUpdated;
+ }
+
+ void _deviceManager_DeviceOptionsUpdated(object sender, GenericEventArgs<DeviceInfo> e)
+ {
+ foreach (var session in Sessions)
+ {
+ if (string.Equals(session.DeviceId, e.Argument.Id))
+ {
+ session.DeviceName = e.Argument.Name;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Adds the parts.
+ /// </summary>
+ /// <param name="sessionFactories">The session factories.</param>
+ public void AddParts(IEnumerable<ISessionControllerFactory> sessionFactories)
+ {
+ _sessionFactories = sessionFactories.ToList();
+ }
+
+ /// <summary>
+ /// Gets all connections.
+ /// </summary>
+ /// <value>All connections.</value>
+ public IEnumerable<SessionInfo> Sessions
+ {
+ get { return _activeConnections.Values.OrderByDescending(c => c.LastActivityDate).ToList(); }
+ }
+
+ private void OnSessionStarted(SessionInfo info)
+ {
+ EventHelper.QueueEventIfNotNull(SessionStarted, this, new SessionEventArgs
+ {
+ SessionInfo = info
+
+ }, _logger);
+
+ if (!string.IsNullOrWhiteSpace(info.DeviceId))
+ {
+ var capabilities = GetSavedCapabilities(info.DeviceId);
+
+ if (capabilities != null)
+ {
+ info.AppIconUrl = capabilities.IconUrl;
+ ReportCapabilities(info, capabilities, false);
+ }
+ }
+ }
+
+ private async void OnSessionEnded(SessionInfo info)
+ {
+ try
+ {
+ await SendSessionEndedNotification(info, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendSessionEndedNotification", ex);
+ }
+
+ EventHelper.QueueEventIfNotNull(SessionEnded, this, new SessionEventArgs
+ {
+ SessionInfo = info
+
+ }, _logger);
+
+ var disposable = info.SessionController as IDisposable;
+
+ if (disposable != null)
+ {
+ _logger.Debug("Disposing session controller {0}", disposable.GetType().Name);
+
+ try
+ {
+ disposable.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error disposing session controller", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Logs the user activity.
+ /// </summary>
+ /// <param name="appName">Type of the client.</param>
+ /// <param name="appVersion">The app version.</param>
+ /// <param name="deviceId">The device id.</param>
+ /// <param name="deviceName">Name of the device.</param>
+ /// <param name="remoteEndPoint">The remote end point.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">user</exception>
+ /// <exception cref="System.UnauthorizedAccessException"></exception>
+ public async Task<SessionInfo> LogSessionActivity(string appName,
+ string appVersion,
+ string deviceId,
+ string deviceName,
+ string remoteEndPoint,
+ User user)
+ {
+ if (string.IsNullOrEmpty(appName))
+ {
+ throw new ArgumentNullException("appName");
+ }
+ if (string.IsNullOrEmpty(appVersion))
+ {
+ throw new ArgumentNullException("appVersion");
+ }
+ if (string.IsNullOrEmpty(deviceId))
+ {
+ throw new ArgumentNullException("deviceId");
+ }
+ if (string.IsNullOrEmpty(deviceName))
+ {
+ throw new ArgumentNullException("deviceName");
+ }
+
+ var activityDate = DateTime.UtcNow;
+ var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
+ var lastActivityDate = session.LastActivityDate;
+ session.LastActivityDate = activityDate;
+
+ if (user != null)
+ {
+ var userLastActivityDate = user.LastActivityDate ?? DateTime.MinValue;
+ user.LastActivityDate = activityDate;
+
+ if ((activityDate - userLastActivityDate).TotalSeconds > 60)
+ {
+ try
+ {
+ await _userManager.UpdateUser(user).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error updating user", ex);
+ }
+ }
+ }
+
+ if ((activityDate - lastActivityDate).TotalSeconds > 10)
+ {
+ EventHelper.FireEventIfNotNull(SessionActivity, this, new SessionEventArgs
+ {
+ SessionInfo = session
+
+ }, _logger);
+ }
+
+ var controller = session.SessionController;
+ if (controller != null)
+ {
+ controller.OnActivity();
+ }
+
+ return session;
+ }
+
+ public async void ReportSessionEnded(string sessionId)
+ {
+ await _sessionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ try
+ {
+ var session = GetSession(sessionId, false);
+
+ if (session != null)
+ {
+ var key = GetSessionKey(session.Client, session.DeviceId);
+
+ SessionInfo removed;
+ _activeConnections.TryRemove(key, out removed);
+
+ OnSessionEnded(session);
+ }
+ }
+ finally
+ {
+ _sessionLock.Release();
+ }
+ }
+
+ private Task<MediaSourceInfo> GetMediaSource(IHasMediaSources item, string mediaSourceId, string liveStreamId)
+ {
+ return _mediaSourceManager.GetMediaSource(item, mediaSourceId, liveStreamId, false, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Updates the now playing item id.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="info">The information.</param>
+ /// <param name="libraryItem">The library item.</param>
+ private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem)
+ {
+ if (string.IsNullOrWhiteSpace(info.MediaSourceId))
+ {
+ info.MediaSourceId = info.ItemId;
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.ItemId) && info.Item == null && libraryItem != null)
+ {
+ var current = session.NowPlayingItem;
+
+ if (current == null || !string.Equals(current.Id, info.ItemId, StringComparison.OrdinalIgnoreCase))
+ {
+ var runtimeTicks = libraryItem.RunTimeTicks;
+
+ MediaSourceInfo mediaSource = null;
+ var hasMediaSources = libraryItem as IHasMediaSources;
+ if (hasMediaSources != null)
+ {
+ mediaSource = await GetMediaSource(hasMediaSources, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false);
+
+ if (mediaSource != null)
+ {
+ runtimeTicks = mediaSource.RunTimeTicks;
+ }
+ }
+
+ info.Item = GetItemInfo(libraryItem, libraryItem, mediaSource);
+
+ info.Item.RunTimeTicks = runtimeTicks;
+ }
+ else
+ {
+ info.Item = current;
+ }
+ }
+
+ session.NowPlayingItem = info.Item;
+ session.LastActivityDate = DateTime.UtcNow;
+ session.LastPlaybackCheckIn = DateTime.UtcNow;
+
+ session.PlayState.IsPaused = info.IsPaused;
+ session.PlayState.PositionTicks = info.PositionTicks;
+ session.PlayState.MediaSourceId = info.MediaSourceId;
+ session.PlayState.CanSeek = info.CanSeek;
+ session.PlayState.IsMuted = info.IsMuted;
+ session.PlayState.VolumeLevel = info.VolumeLevel;
+ session.PlayState.AudioStreamIndex = info.AudioStreamIndex;
+ session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex;
+ session.PlayState.PlayMethod = info.PlayMethod;
+ session.PlayState.RepeatMode = info.RepeatMode;
+ }
+
+ /// <summary>
+ /// Removes the now playing item id.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ private void RemoveNowPlayingItem(SessionInfo session)
+ {
+ session.NowPlayingItem = null;
+ session.PlayState = new PlayerStateInfo();
+
+ if (!string.IsNullOrEmpty(session.DeviceId))
+ {
+ ClearTranscodingInfo(session.DeviceId);
+ }
+ }
+
+ private string GetSessionKey(string appName, string deviceId)
+ {
+ return appName + deviceId;
+ }
+
+ /// <summary>
+ /// Gets the connection.
+ /// </summary>
+ /// <param name="appName">Type of the client.</param>
+ /// <param name="appVersion">The app version.</param>
+ /// <param name="deviceId">The device id.</param>
+ /// <param name="deviceName">Name of the device.</param>
+ /// <param name="remoteEndPoint">The remote end point.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>SessionInfo.</returns>
+ private async Task<SessionInfo> GetSessionInfo(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ {
+ throw new ArgumentNullException("deviceId");
+ }
+ var key = GetSessionKey(appName, deviceId);
+
+ await _sessionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ var userId = user == null ? (Guid?)null : user.Id;
+ var username = user == null ? null : user.Name;
+
+ try
+ {
+ SessionInfo sessionInfo;
+ DeviceInfo device = null;
+
+ if (!_activeConnections.TryGetValue(key, out sessionInfo))
+ {
+ sessionInfo = new SessionInfo
+ {
+ Client = appName,
+ DeviceId = deviceId,
+ ApplicationVersion = appVersion,
+ Id = key.GetMD5().ToString("N")
+ };
+
+ sessionInfo.DeviceName = deviceName;
+ sessionInfo.UserId = userId;
+ sessionInfo.UserName = username;
+ sessionInfo.RemoteEndPoint = remoteEndPoint;
+
+ OnSessionStarted(sessionInfo);
+
+ _activeConnections.TryAdd(key, sessionInfo);
+
+ if (!string.IsNullOrEmpty(deviceId))
+ {
+ var userIdString = userId.HasValue ? userId.Value.ToString("N") : null;
+ device = await _deviceManager.RegisterDevice(deviceId, deviceName, appName, appVersion, userIdString).ConfigureAwait(false);
+ }
+ }
+
+ device = device ?? _deviceManager.GetDevice(deviceId);
+
+ if (device == null)
+ {
+ var userIdString = userId.HasValue ? userId.Value.ToString("N") : null;
+ device = await _deviceManager.RegisterDevice(deviceId, deviceName, appName, appVersion, userIdString).ConfigureAwait(false);
+ }
+
+ if (device != null)
+ {
+ if (!string.IsNullOrEmpty(device.CustomName))
+ {
+ deviceName = device.CustomName;
+ }
+ }
+
+ sessionInfo.DeviceName = deviceName;
+ sessionInfo.UserId = userId;
+ sessionInfo.UserName = username;
+ sessionInfo.RemoteEndPoint = remoteEndPoint;
+ sessionInfo.ApplicationVersion = appVersion;
+
+ if (!userId.HasValue)
+ {
+ sessionInfo.AdditionalUsers.Clear();
+ }
+
+ if (sessionInfo.SessionController == null)
+ {
+ sessionInfo.SessionController = _sessionFactories
+ .Select(i => i.GetSessionController(sessionInfo))
+ .FirstOrDefault(i => i != null);
+ }
+
+ return sessionInfo;
+ }
+ finally
+ {
+ _sessionLock.Release();
+ }
+ }
+
+ private List<User> GetUsers(SessionInfo session)
+ {
+ var users = new List<User>();
+
+ if (session.UserId.HasValue)
+ {
+ var user = _userManager.GetUserById(session.UserId.Value);
+
+ if (user == null)
+ {
+ throw new InvalidOperationException("User not found");
+ }
+
+ users.Add(user);
+
+ var additionalUsers = session.AdditionalUsers
+ .Select(i => _userManager.GetUserById(i.UserId))
+ .Where(i => i != null);
+
+ users.AddRange(additionalUsers);
+ }
+
+ return users;
+ }
+
+ private ITimer _idleTimer;
+
+ private void StartIdleCheckTimer()
+ {
+ if (_idleTimer == null)
+ {
+ _idleTimer = _timerFactory.Create(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
+ }
+ }
+ private void StopIdleCheckTimer()
+ {
+ if (_idleTimer != null)
+ {
+ _idleTimer.Dispose();
+ _idleTimer = null;
+ }
+ }
+
+ private async void CheckForIdlePlayback(object state)
+ {
+ var playingSessions = Sessions.Where(i => i.NowPlayingItem != null)
+ .ToList();
+
+ if (playingSessions.Count > 0)
+ {
+ var idle = playingSessions
+ .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
+ .ToList();
+
+ foreach (var session in idle)
+ {
+ _logger.Debug("Session {0} has gone idle while playing", session.Id);
+
+ try
+ {
+ await OnPlaybackStopped(new PlaybackStopInfo
+ {
+ Item = session.NowPlayingItem,
+ ItemId = session.NowPlayingItem == null ? null : session.NowPlayingItem.Id,
+ SessionId = session.Id,
+ MediaSourceId = session.PlayState == null ? null : session.PlayState.MediaSourceId,
+ PositionTicks = session.PlayState == null ? null : session.PlayState.PositionTicks
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug("Error calling OnPlaybackStopped", ex);
+ }
+ }
+
+ playingSessions = Sessions.Where(i => i.NowPlayingItem != null)
+ .ToList();
+ }
+
+ if (playingSessions.Count == 0)
+ {
+ StopIdleCheckTimer();
+ }
+ }
+
+ private BaseItem GetNowPlayingItem(SessionInfo session, string itemId)
+ {
+ var idGuid = new Guid(itemId);
+
+ var item = session.FullNowPlayingItem;
+ if (item != null && item.Id == idGuid)
+ {
+ return item;
+ }
+
+ item = _libraryManager.GetItemById(itemId);
+
+ session.FullNowPlayingItem = item;
+
+ return item;
+ }
+
+ /// <summary>
+ /// Used to report that playback has started for an item
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">info</exception>
+ public async Task OnPlaybackStart(PlaybackStartInfo info)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+
+ var session = GetSession(info.SessionId);
+
+ var libraryItem = string.IsNullOrWhiteSpace(info.ItemId)
+ ? null
+ : GetNowPlayingItem(session, info.ItemId);
+
+ await UpdateNowPlayingItem(session, info, libraryItem).ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(session.DeviceId) && info.PlayMethod != PlayMethod.Transcode)
+ {
+ ClearTranscodingInfo(session.DeviceId);
+ }
+
+ session.QueueableMediaTypes = info.QueueableMediaTypes;
+
+ var users = GetUsers(session);
+
+ if (libraryItem != null)
+ {
+ foreach (var user in users)
+ {
+ await OnPlaybackStart(user.Id, libraryItem).ConfigureAwait(false);
+ }
+ }
+
+ // Nothing to save here
+ // Fire events to inform plugins
+ EventHelper.QueueEventIfNotNull(PlaybackStart, this, new PlaybackProgressEventArgs
+ {
+ Item = libraryItem,
+ Users = users,
+ MediaSourceId = info.MediaSourceId,
+ MediaInfo = info.Item,
+ DeviceName = session.DeviceName,
+ ClientName = session.Client,
+ DeviceId = session.DeviceId
+
+ }, _logger);
+
+ await SendPlaybackStartNotification(session, CancellationToken.None).ConfigureAwait(false);
+
+ StartIdleCheckTimer();
+ }
+
+ /// <summary>
+ /// Called when [playback start].
+ /// </summary>
+ /// <param name="userId">The user identifier.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>Task.</returns>
+ private async Task OnPlaybackStart(Guid userId, IHasUserData item)
+ {
+ var data = _userDataManager.GetUserData(userId, item);
+
+ data.PlayCount++;
+ data.LastPlayedDate = DateTime.UtcNow;
+
+ if (item.SupportsPlayedStatus)
+ {
+ if (!(item is Video))
+ {
+ data.Played = true;
+ }
+ }
+ else
+ {
+ data.Played = false;
+ }
+
+ await _userDataManager.SaveUserData(userId, item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Used to report playback progress for an item
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException"></exception>
+ /// <exception cref="System.ArgumentOutOfRangeException">positionTicks</exception>
+ public async Task OnPlaybackProgress(PlaybackProgressInfo info)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+
+ var session = GetSession(info.SessionId);
+
+ var libraryItem = string.IsNullOrWhiteSpace(info.ItemId)
+ ? null
+ : GetNowPlayingItem(session, info.ItemId);
+
+ await UpdateNowPlayingItem(session, info, libraryItem).ConfigureAwait(false);
+
+ var users = GetUsers(session);
+
+ if (libraryItem != null)
+ {
+ foreach (var user in users)
+ {
+ await OnPlaybackProgress(user, libraryItem, info).ConfigureAwait(false);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.LiveStreamId))
+ {
+ try
+ {
+ await _mediaSourceManager.PingLiveStream(info.LiveStreamId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing live stream", ex);
+ }
+ }
+
+ EventHelper.FireEventIfNotNull(PlaybackProgress, this, new PlaybackProgressEventArgs
+ {
+ Item = libraryItem,
+ Users = users,
+ PlaybackPositionTicks = session.PlayState.PositionTicks,
+ MediaSourceId = session.PlayState.MediaSourceId,
+ MediaInfo = info.Item,
+ DeviceName = session.DeviceName,
+ ClientName = session.Client,
+ DeviceId = session.DeviceId,
+ IsPaused = info.IsPaused,
+ PlaySessionId = info.PlaySessionId
+
+ }, _logger);
+
+ StartIdleCheckTimer();
+ }
+
+ private async Task OnPlaybackProgress(User user, BaseItem item, PlaybackProgressInfo info)
+ {
+ var data = _userDataManager.GetUserData(user.Id, item);
+
+ var positionTicks = info.PositionTicks;
+
+ if (positionTicks.HasValue)
+ {
+ _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
+
+ UpdatePlaybackSettings(user, info, data);
+
+ await _userDataManager.SaveUserData(user.Id, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ private void UpdatePlaybackSettings(User user, PlaybackProgressInfo info, UserItemData data)
+ {
+ if (user.Configuration.RememberAudioSelections)
+ {
+ data.AudioStreamIndex = info.AudioStreamIndex;
+ }
+ else
+ {
+ data.AudioStreamIndex = null;
+ }
+
+ if (user.Configuration.RememberSubtitleSelections)
+ {
+ data.SubtitleStreamIndex = info.SubtitleStreamIndex;
+ }
+ else
+ {
+ data.SubtitleStreamIndex = null;
+ }
+ }
+
+ /// <summary>
+ /// Used to report that playback has ended for an item
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">info</exception>
+ /// <exception cref="System.ArgumentOutOfRangeException">positionTicks</exception>
+ public async Task OnPlaybackStopped(PlaybackStopInfo info)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+
+ if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
+ {
+ throw new ArgumentOutOfRangeException("positionTicks");
+ }
+
+ var session = GetSession(info.SessionId);
+
+ var libraryItem = string.IsNullOrWhiteSpace(info.ItemId)
+ ? null
+ : GetNowPlayingItem(session, info.ItemId);
+
+ // Normalize
+ if (string.IsNullOrWhiteSpace(info.MediaSourceId))
+ {
+ info.MediaSourceId = info.ItemId;
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.ItemId) && info.Item == null && libraryItem != null)
+ {
+ var current = session.NowPlayingItem;
+
+ if (current == null || !string.Equals(current.Id, info.ItemId, StringComparison.OrdinalIgnoreCase))
+ {
+ MediaSourceInfo mediaSource = null;
+
+ var hasMediaSources = libraryItem as IHasMediaSources;
+ if (hasMediaSources != null)
+ {
+ mediaSource = await GetMediaSource(hasMediaSources, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false);
+ }
+
+ info.Item = GetItemInfo(libraryItem, libraryItem, mediaSource);
+ }
+ else
+ {
+ info.Item = current;
+ }
+ }
+
+ if (info.Item != null)
+ {
+ var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown";
+
+ _logger.Info("Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms",
+ session.Client,
+ session.ApplicationVersion,
+ info.Item.Name,
+ msString);
+ }
+
+ RemoveNowPlayingItem(session);
+
+ var users = GetUsers(session);
+ var playedToCompletion = false;
+
+ if (libraryItem != null)
+ {
+ foreach (var user in users)
+ {
+ playedToCompletion = await OnPlaybackStopped(user.Id, libraryItem, info.PositionTicks, info.Failed).ConfigureAwait(false);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(info.LiveStreamId))
+ {
+ try
+ {
+ await _mediaSourceManager.CloseLiveStream(info.LiveStreamId).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error closing live stream", ex);
+ }
+ }
+
+ EventHelper.QueueEventIfNotNull(PlaybackStopped, this, new PlaybackStopEventArgs
+ {
+ Item = libraryItem,
+ Users = users,
+ PlaybackPositionTicks = info.PositionTicks,
+ PlayedToCompletion = playedToCompletion,
+ MediaSourceId = info.MediaSourceId,
+ MediaInfo = info.Item,
+ DeviceName = session.DeviceName,
+ ClientName = session.Client,
+ DeviceId = session.DeviceId
+
+ }, _logger);
+
+ await SendPlaybackStoppedNotification(session, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private async Task<bool> OnPlaybackStopped(Guid userId, BaseItem item, long? positionTicks, bool playbackFailed)
+ {
+ bool playedToCompletion = false;
+
+ if (!playbackFailed)
+ {
+ var data = _userDataManager.GetUserData(userId, item);
+
+ if (positionTicks.HasValue)
+ {
+ playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value);
+ }
+ else
+ {
+ // If the client isn't able to report this, then we'll just have to make an assumption
+ data.PlayCount++;
+ data.Played = item.SupportsPlayedStatus;
+ data.PlaybackPositionTicks = 0;
+ playedToCompletion = true;
+ }
+
+ await _userDataManager.SaveUserData(userId, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ return playedToCompletion;
+ }
+
+ /// <summary>
+ /// Gets the session.
+ /// </summary>
+ /// <param name="sessionId">The session identifier.</param>
+ /// <param name="throwOnMissing">if set to <c>true</c> [throw on missing].</param>
+ /// <returns>SessionInfo.</returns>
+ /// <exception cref="ResourceNotFoundException"></exception>
+ private SessionInfo GetSession(string sessionId, bool throwOnMissing = true)
+ {
+ var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId));
+
+ if (session == null && throwOnMissing)
+ {
+ throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId));
+ }
+
+ return session;
+ }
+
+ private SessionInfo GetSessionToRemoteControl(string sessionId)
+ {
+ // Accept either device id or session id
+ var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId));
+
+ if (session == null)
+ {
+ throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId));
+ }
+
+ return session;
+ }
+
+ public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken)
+ {
+ var generalCommand = new GeneralCommand
+ {
+ Name = GeneralCommandType.DisplayMessage.ToString()
+ };
+
+ generalCommand.Arguments["Header"] = command.Header;
+ generalCommand.Arguments["Text"] = command.Text;
+
+ if (command.TimeoutMs.HasValue)
+ {
+ generalCommand.Arguments["TimeoutMs"] = command.TimeoutMs.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken);
+ }
+
+ public Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken)
+ {
+ var session = GetSessionToRemoteControl(sessionId);
+
+ var controllingSession = GetSession(controllingSessionId);
+ AssertCanControl(session, controllingSession);
+
+ return session.SessionController.SendGeneralCommand(command, cancellationToken);
+ }
+
+ public async Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken)
+ {
+ var session = GetSessionToRemoteControl(sessionId);
+
+ var user = session.UserId.HasValue ? _userManager.GetUserById(session.UserId.Value) : null;
+
+ List<BaseItem> items;
+
+ if (command.PlayCommand == PlayCommand.PlayInstantMix)
+ {
+ items = command.ItemIds.SelectMany(i => TranslateItemForInstantMix(i, user))
+ .Where(i => i.LocationType != LocationType.Virtual)
+ .ToList();
+
+ command.PlayCommand = PlayCommand.PlayNow;
+ }
+ else
+ {
+ var list = new List<BaseItem>();
+ foreach (var itemId in command.ItemIds)
+ {
+ var subItems = await TranslateItemForPlayback(itemId, user).ConfigureAwait(false);
+ list.AddRange(subItems);
+ }
+
+ items = list
+ .Where(i => i.LocationType != LocationType.Virtual)
+ .ToList();
+ }
+
+ if (command.PlayCommand == PlayCommand.PlayShuffle)
+ {
+ items = items.OrderBy(i => Guid.NewGuid()).ToList();
+ command.PlayCommand = PlayCommand.PlayNow;
+ }
+
+ command.ItemIds = items.Select(i => i.Id.ToString("N")).ToArray();
+
+ if (user != null)
+ {
+ if (items.Any(i => i.GetPlayAccess(user) != PlayAccess.Full))
+ {
+ throw new ArgumentException(string.Format("{0} is not allowed to play media.", user.Name));
+ }
+ }
+
+ if (command.PlayCommand != PlayCommand.PlayNow)
+ {
+ if (items.Any(i => !session.QueueableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(string.Format("{0} is unable to queue the requested media type.", session.DeviceName ?? session.Id));
+ }
+ }
+ else
+ {
+ if (items.Any(i => !session.PlayableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase)))
+ {
+ throw new ArgumentException(string.Format("{0} is unable to play the requested media type.", session.DeviceName ?? session.Id));
+ }
+ }
+
+ if (user != null && command.ItemIds.Length == 1 && user.Configuration.EnableNextEpisodeAutoPlay)
+ {
+ var episode = _libraryManager.GetItemById(command.ItemIds[0]) as Episode;
+ if (episode != null)
+ {
+ var series = episode.Series;
+ if (series != null)
+ {
+ var episodes = series.GetEpisodes(user)
+ .Where(i => !i.IsVirtualItem)
+ .SkipWhile(i => i.Id != episode.Id)
+ .ToList();
+
+ if (episodes.Count > 0)
+ {
+ command.ItemIds = episodes.Select(i => i.Id.ToString("N")).ToArray();
+ }
+ }
+ }
+ }
+
+ var controllingSession = GetSession(controllingSessionId);
+ AssertCanControl(session, controllingSession);
+ if (controllingSession.UserId.HasValue)
+ {
+ command.ControllingUserId = controllingSession.UserId.Value.ToString("N");
+ }
+
+ await session.SessionController.SendPlayCommand(command, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task<List<BaseItem>> TranslateItemForPlayback(string id, User user)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ _logger.Error("A non-existant item Id {0} was passed into TranslateItemForPlayback", id);
+ return new List<BaseItem>();
+ }
+
+ var byName = item as IItemByName;
+
+ if (byName != null)
+ {
+ var items = byName.GetTaggedItems(new InternalItemsQuery(user)
+ {
+ IsFolder = false,
+ Recursive = true
+ });
+
+ return FilterToSingleMediaType(items)
+ .OrderBy(i => i.SortName)
+ .ToList();
+ }
+
+ if (item.IsFolder)
+ {
+ var folder = (Folder)item;
+
+ var itemsResult = await folder.GetItems(new InternalItemsQuery(user)
+ {
+ Recursive = true,
+ IsFolder = false
+
+ }).ConfigureAwait(false);
+
+ return FilterToSingleMediaType(itemsResult.Items)
+ .OrderBy(i => i.SortName)
+ .ToList();
+ }
+
+ return new List<BaseItem> { item };
+ }
+
+ private IEnumerable<BaseItem> FilterToSingleMediaType(IEnumerable<BaseItem> items)
+ {
+ return items
+ .Where(i => !string.IsNullOrWhiteSpace(i.MediaType))
+ .ToLookup(i => i.MediaType, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(i => i.Count())
+ .FirstOrDefault();
+ }
+
+ private IEnumerable<BaseItem> TranslateItemForInstantMix(string id, User user)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ _logger.Error("A non-existant item Id {0} was passed into TranslateItemForInstantMix", id);
+ return new List<BaseItem>();
+ }
+
+ return _musicManager.GetInstantMixFromItem(item, user);
+ }
+
+ public Task SendBrowseCommand(string controllingSessionId, string sessionId, BrowseRequest command, CancellationToken cancellationToken)
+ {
+ var generalCommand = new GeneralCommand
+ {
+ Name = GeneralCommandType.DisplayContent.ToString()
+ };
+
+ generalCommand.Arguments["ItemId"] = command.ItemId;
+ generalCommand.Arguments["ItemName"] = command.ItemName;
+ generalCommand.Arguments["ItemType"] = command.ItemType;
+
+ return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken);
+ }
+
+ public Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken)
+ {
+ var session = GetSessionToRemoteControl(sessionId);
+
+ var controllingSession = GetSession(controllingSessionId);
+ AssertCanControl(session, controllingSession);
+ if (controllingSession.UserId.HasValue)
+ {
+ command.ControllingUserId = controllingSession.UserId.Value.ToString("N");
+ }
+
+ return session.SessionController.SendPlaystateCommand(command, cancellationToken);
+ }
+
+ private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
+ {
+ if (session == null)
+ {
+ throw new ArgumentNullException("session");
+ }
+ if (controllingSession == null)
+ {
+ throw new ArgumentNullException("controllingSession");
+ }
+ }
+
+ /// <summary>
+ /// Sends the restart required message.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task SendRestartRequiredNotification(CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+
+ var info = await _appHost.GetSystemInfo().ConfigureAwait(false);
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendRestartRequiredNotification(info, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendRestartRequiredNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Sends the server shutdown notification.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendServerShutdownNotification(CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendServerShutdownNotification(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendServerShutdownNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ /// <summary>
+ /// Sends the server restart notification.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendServerRestartNotification(CancellationToken cancellationToken)
+ {
+ _logger.Debug("Beginning SendServerRestartNotification");
+
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendServerRestartNotification(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendServerRestartNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ public Task SendSessionEndedNotification(SessionInfo sessionInfo, CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+ var dto = GetSessionInfoDto(sessionInfo);
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendSessionEndedNotification(dto, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendSessionEndedNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ public Task SendPlaybackStartNotification(SessionInfo sessionInfo, CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+ var dto = GetSessionInfoDto(sessionInfo);
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendPlaybackStartNotification(dto, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendPlaybackStartNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ public Task SendPlaybackStoppedNotification(SessionInfo sessionInfo, CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList();
+ var dto = GetSessionInfoDto(sessionInfo);
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendPlaybackStoppedNotification(dto, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SendPlaybackStoppedNotification.", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ /// <summary>
+ /// Adds the additional user.
+ /// </summary>
+ /// <param name="sessionId">The session identifier.</param>
+ /// <param name="userId">The user identifier.</param>
+ /// <exception cref="System.UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="System.ArgumentException">The requested user is already the primary user of the session.</exception>
+ public void AddAdditionalUser(string sessionId, string userId)
+ {
+ var session = GetSession(sessionId);
+
+ if (session.UserId.HasValue && session.UserId.Value == new Guid(userId))
+ {
+ throw new ArgumentException("The requested user is already the primary user of the session.");
+ }
+
+ if (session.AdditionalUsers.All(i => new Guid(i.UserId) != new Guid(userId)))
+ {
+ var user = _userManager.GetUserById(userId);
+
+ session.AdditionalUsers.Add(new SessionUserInfo
+ {
+ UserId = userId,
+ UserName = user.Name
+ });
+ }
+ }
+
+ /// <summary>
+ /// Removes the additional user.
+ /// </summary>
+ /// <param name="sessionId">The session identifier.</param>
+ /// <param name="userId">The user identifier.</param>
+ /// <exception cref="System.UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="System.ArgumentException">The requested user is already the primary user of the session.</exception>
+ public void RemoveAdditionalUser(string sessionId, string userId)
+ {
+ var session = GetSession(sessionId);
+
+ if (session.UserId.HasValue && session.UserId.Value == new Guid(userId))
+ {
+ throw new ArgumentException("The requested user is already the primary user of the session.");
+ }
+
+ var user = session.AdditionalUsers.FirstOrDefault(i => new Guid(i.UserId) == new Guid(userId));
+
+ if (user != null)
+ {
+ session.AdditionalUsers.Remove(user);
+ }
+ }
+
+ /// <summary>
+ /// Authenticates the new session.
+ /// </summary>
+ /// <param name="request">The request.</param>
+ /// <returns>Task{SessionInfo}.</returns>
+ public Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request)
+ {
+ return AuthenticateNewSessionInternal(request, true);
+ }
+
+ public Task<AuthenticationResult> CreateNewSession(AuthenticationRequest request)
+ {
+ return AuthenticateNewSessionInternal(request, false);
+ }
+
+ private async Task<AuthenticationResult> AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword)
+ {
+ User user = null;
+ if (!string.IsNullOrWhiteSpace(request.UserId))
+ {
+ var idGuid = new Guid(request.UserId);
+ user = _userManager.Users
+ .FirstOrDefault(i => i.Id == idGuid);
+ }
+
+ if (user == null)
+ {
+ user = _userManager.Users
+ .FirstOrDefault(i => string.Equals(request.Username, i.Name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (user != null && !string.IsNullOrWhiteSpace(request.DeviceId))
+ {
+ if (!_deviceManager.CanAccessDevice(user.Id.ToString("N"), request.DeviceId))
+ {
+ throw new SecurityException("User is not allowed access from this device.");
+ }
+ }
+
+ if (enforcePassword)
+ {
+ var result = await _userManager.AuthenticateUser(request.Username, request.PasswordSha1, request.PasswordMd5, request.RemoteEndPoint).ConfigureAwait(false);
+
+ if (!result)
+ {
+ EventHelper.FireEventIfNotNull(AuthenticationFailed, this, new GenericEventArgs<AuthenticationRequest>(request), _logger);
+
+ throw new SecurityException("Invalid user or password entered.");
+ }
+ }
+
+ var token = await GetAuthorizationToken(user.Id.ToString("N"), request.DeviceId, request.App, request.AppVersion, request.DeviceName).ConfigureAwait(false);
+
+ EventHelper.FireEventIfNotNull(AuthenticationSucceeded, this, new GenericEventArgs<AuthenticationRequest>(request), _logger);
+
+ var session = await LogSessionActivity(request.App,
+ request.AppVersion,
+ request.DeviceId,
+ request.DeviceName,
+ request.RemoteEndPoint,
+ user)
+ .ConfigureAwait(false);
+
+ return new AuthenticationResult
+ {
+ User = _userManager.GetUserDto(user, request.RemoteEndPoint),
+ SessionInfo = GetSessionInfoDto(session),
+ AccessToken = token,
+ ServerId = _appHost.SystemId
+ };
+ }
+
+
+ private async Task<string> GetAuthorizationToken(string userId, string deviceId, string app, string appVersion, string deviceName)
+ {
+ var existing = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ DeviceId = deviceId,
+ IsActive = true,
+ UserId = userId,
+ Limit = 1
+ });
+
+ if (existing.Items.Length > 0)
+ {
+ var token = existing.Items[0].AccessToken;
+ _logger.Info("Reissuing access token: " + token);
+ return token;
+ }
+
+ var newToken = new AuthenticationInfo
+ {
+ AppName = app,
+ AppVersion = appVersion,
+ DateCreated = DateTime.UtcNow,
+ DeviceId = deviceId,
+ DeviceName = deviceName,
+ UserId = userId,
+ IsActive = true,
+ AccessToken = Guid.NewGuid().ToString("N")
+ };
+
+ _logger.Info("Creating new access token for user {0}", userId);
+ await _authRepo.Create(newToken, CancellationToken.None).ConfigureAwait(false);
+
+ return newToken.AccessToken;
+ }
+
+ public async Task Logout(string accessToken)
+ {
+ if (string.IsNullOrWhiteSpace(accessToken))
+ {
+ throw new ArgumentNullException("accessToken");
+ }
+
+ _logger.Info("Logging out access token {0}", accessToken);
+
+ var existing = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ Limit = 1,
+ AccessToken = accessToken
+
+ }).Items.FirstOrDefault();
+
+ if (existing != null)
+ {
+ existing.IsActive = false;
+
+ await _authRepo.Update(existing, CancellationToken.None).ConfigureAwait(false);
+
+ var sessions = Sessions
+ .Where(i => string.Equals(i.DeviceId, existing.DeviceId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var session in sessions)
+ {
+ try
+ {
+ ReportSessionEnded(session.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reporting session ended", ex);
+ }
+ }
+ }
+ }
+
+ public async Task RevokeUserTokens(string userId, string currentAccessToken)
+ {
+ var existing = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ IsActive = true,
+ UserId = userId
+ });
+
+ foreach (var info in existing.Items)
+ {
+ if (!string.Equals(currentAccessToken, info.AccessToken, StringComparison.OrdinalIgnoreCase))
+ {
+ await Logout(info.AccessToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public Task RevokeToken(string token)
+ {
+ return Logout(token);
+ }
+
+ /// <summary>
+ /// Reports the capabilities.
+ /// </summary>
+ /// <param name="sessionId">The session identifier.</param>
+ /// <param name="capabilities">The capabilities.</param>
+ public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
+ {
+ var session = GetSession(sessionId);
+
+ ReportCapabilities(session, capabilities, true);
+ }
+
+ private async void ReportCapabilities(SessionInfo session,
+ ClientCapabilities capabilities,
+ bool saveCapabilities)
+ {
+ session.Capabilities = capabilities;
+
+ if (!string.IsNullOrWhiteSpace(capabilities.MessageCallbackUrl))
+ {
+ var controller = session.SessionController as HttpSessionController;
+
+ if (controller == null)
+ {
+ session.SessionController = new HttpSessionController(_httpClient, _jsonSerializer, session, capabilities.MessageCallbackUrl, this);
+ }
+ }
+
+ EventHelper.FireEventIfNotNull(CapabilitiesChanged, this, new SessionEventArgs
+ {
+ SessionInfo = session
+
+ }, _logger);
+
+ if (saveCapabilities)
+ {
+ try
+ {
+ await SaveCapabilities(session.DeviceId, capabilities).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error saving device capabilities", ex);
+ }
+ }
+ }
+
+ private ClientCapabilities GetSavedCapabilities(string deviceId)
+ {
+ return _deviceManager.GetCapabilities(deviceId);
+ }
+
+ private Task SaveCapabilities(string deviceId, ClientCapabilities capabilities)
+ {
+ return _deviceManager.SaveCapabilities(deviceId, capabilities);
+ }
+
+ public SessionInfoDto GetSessionInfoDto(SessionInfo session)
+ {
+ var dto = new SessionInfoDto
+ {
+ Client = session.Client,
+ DeviceId = session.DeviceId,
+ DeviceName = session.DeviceName,
+ Id = session.Id,
+ LastActivityDate = session.LastActivityDate,
+ NowViewingItem = session.NowViewingItem,
+ ApplicationVersion = session.ApplicationVersion,
+ QueueableMediaTypes = session.QueueableMediaTypes,
+ PlayableMediaTypes = session.PlayableMediaTypes,
+ AdditionalUsers = session.AdditionalUsers,
+ SupportedCommands = session.SupportedCommands,
+ UserName = session.UserName,
+ NowPlayingItem = session.NowPlayingItem,
+ SupportsRemoteControl = session.SupportsMediaControl,
+ PlayState = session.PlayState,
+ AppIconUrl = session.AppIconUrl,
+ TranscodingInfo = session.NowPlayingItem == null ? null : session.TranscodingInfo
+ };
+
+ if (session.UserId.HasValue)
+ {
+ dto.UserId = session.UserId.Value.ToString("N");
+
+ var user = _userManager.GetUserById(session.UserId.Value);
+
+ if (user != null)
+ {
+ dto.UserPrimaryImageTag = GetImageCacheTag(user, ImageType.Primary);
+ }
+ }
+
+ return dto;
+ }
+
+ /// <summary>
+ /// Converts a BaseItem to a BaseItemInfo
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="chapterOwner">The chapter owner.</param>
+ /// <param name="mediaSource">The media source.</param>
+ /// <returns>BaseItemInfo.</returns>
+ /// <exception cref="System.ArgumentNullException">item</exception>
+ private BaseItemInfo GetItemInfo(BaseItem item, BaseItem chapterOwner, MediaSourceInfo mediaSource)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ var info = new BaseItemInfo
+ {
+ Id = GetDtoId(item),
+ Name = item.Name,
+ MediaType = item.MediaType,
+ Type = item.GetClientTypeName(),
+ RunTimeTicks = item.RunTimeTicks,
+ IndexNumber = item.IndexNumber,
+ ParentIndexNumber = item.ParentIndexNumber,
+ PremiereDate = item.PremiereDate,
+ ProductionYear = item.ProductionYear,
+ IsThemeMedia = item.IsThemeMedia
+ };
+
+ info.PrimaryImageTag = GetImageCacheTag(item, ImageType.Primary);
+ if (info.PrimaryImageTag != null)
+ {
+ info.PrimaryImageItemId = GetDtoId(item);
+ }
+
+ var episode = item as Episode;
+ if (episode != null)
+ {
+ info.IndexNumberEnd = episode.IndexNumberEnd;
+ }
+
+ var hasSeries = item as IHasSeries;
+ if (hasSeries != null)
+ {
+ info.SeriesName = hasSeries.SeriesName;
+ }
+
+ var recording = item as ILiveTvRecording;
+ if (recording != null)
+ {
+ if (recording.IsSeries)
+ {
+ info.Name = recording.EpisodeTitle;
+ info.SeriesName = recording.Name;
+
+ if (string.IsNullOrWhiteSpace(info.Name))
+ {
+ info.Name = recording.Name;
+ }
+ }
+ }
+
+ var audio = item as Audio;
+ if (audio != null)
+ {
+ info.Album = audio.Album;
+ info.Artists = audio.Artists;
+
+ if (info.PrimaryImageTag == null)
+ {
+ var album = audio.AlbumEntity;
+
+ if (album != null && album.HasImage(ImageType.Primary))
+ {
+ info.PrimaryImageTag = GetImageCacheTag(album, ImageType.Primary);
+ if (info.PrimaryImageTag != null)
+ {
+ info.PrimaryImageItemId = GetDtoId(album);
+ }
+ }
+ }
+ }
+
+ var musicVideo = item as MusicVideo;
+ if (musicVideo != null)
+ {
+ info.Album = musicVideo.Album;
+ info.Artists = musicVideo.Artists.ToList();
+ }
+
+ var backropItem = item.HasImage(ImageType.Backdrop) ? item : null;
+ var thumbItem = item.HasImage(ImageType.Thumb) ? item : null;
+ var logoItem = item.HasImage(ImageType.Logo) ? item : null;
+
+ if (thumbItem == null)
+ {
+ if (episode != null)
+ {
+ var series = episode.Series;
+
+ if (series != null && series.HasImage(ImageType.Thumb))
+ {
+ thumbItem = series;
+ }
+ }
+ }
+
+ if (backropItem == null)
+ {
+ if (episode != null)
+ {
+ var series = episode.Series;
+
+ if (series != null && series.HasImage(ImageType.Backdrop))
+ {
+ backropItem = series;
+ }
+ }
+ }
+
+ if (backropItem == null)
+ {
+ backropItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Backdrop));
+ }
+
+ if (thumbItem == null)
+ {
+ thumbItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Thumb));
+ }
+
+ if (logoItem == null)
+ {
+ logoItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Logo));
+ }
+
+ if (thumbItem != null)
+ {
+ info.ThumbImageTag = GetImageCacheTag(thumbItem, ImageType.Thumb);
+ info.ThumbItemId = GetDtoId(thumbItem);
+ }
+
+ if (backropItem != null)
+ {
+ info.BackdropImageTag = GetImageCacheTag(backropItem, ImageType.Backdrop);
+ info.BackdropItemId = GetDtoId(backropItem);
+ }
+
+ if (logoItem != null)
+ {
+ info.LogoImageTag = GetImageCacheTag(logoItem, ImageType.Logo);
+ info.LogoItemId = GetDtoId(logoItem);
+ }
+
+ if (chapterOwner != null)
+ {
+ info.ChapterImagesItemId = chapterOwner.Id.ToString("N");
+
+ info.Chapters = _dtoService.GetChapterInfoDtos(chapterOwner).ToList();
+ }
+
+ if (mediaSource != null)
+ {
+ info.MediaStreams = mediaSource.MediaStreams;
+ }
+
+ return info;
+ }
+
+ private string GetImageCacheTag(BaseItem item, ImageType type)
+ {
+ try
+ {
+ return _imageProcessor.GetImageCacheTag(item, type);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting {0} image info", ex, type);
+ return null;
+ }
+ }
+
+ private string GetDtoId(BaseItem item)
+ {
+ return _dtoService.GetDtoId(item);
+ }
+
+ public void ReportNowViewingItem(string sessionId, string itemId)
+ {
+ if (string.IsNullOrWhiteSpace(itemId))
+ {
+ throw new ArgumentNullException("itemId");
+ }
+
+ //var item = _libraryManager.GetItemById(new Guid(itemId));
+
+ //var info = GetItemInfo(item, null, null);
+
+ //ReportNowViewingItem(sessionId, info);
+ }
+
+ public void ReportNowViewingItem(string sessionId, BaseItemInfo item)
+ {
+ //var session = GetSession(sessionId);
+
+ //session.NowViewingItem = item;
+ }
+
+ public void ReportTranscodingInfo(string deviceId, TranscodingInfo info)
+ {
+ var session = Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId));
+
+ if (session != null)
+ {
+ session.TranscodingInfo = info;
+ }
+ }
+
+ public void ClearTranscodingInfo(string deviceId)
+ {
+ ReportTranscodingInfo(deviceId, null);
+ }
+
+ public SessionInfo GetSession(string deviceId, string client, string version)
+ {
+ return Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId) &&
+ string.Equals(i.Client, client));
+ }
+
+ public Task<SessionInfo> GetSessionByAuthenticationToken(AuthenticationInfo info, string deviceId, string remoteEndpoint, string appVersion)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+
+ var user = string.IsNullOrWhiteSpace(info.UserId)
+ ? null
+ : _userManager.GetUserById(info.UserId);
+
+ appVersion = string.IsNullOrWhiteSpace(appVersion)
+ ? info.AppVersion
+ : appVersion;
+
+ var deviceName = info.DeviceName;
+ var appName = info.AppName;
+
+ if (!string.IsNullOrWhiteSpace(deviceId))
+ {
+ // Replace the info from the token with more recent info
+ var device = _deviceManager.GetDevice(deviceId);
+ if (device != null)
+ {
+ deviceName = device.Name;
+ appName = device.AppName;
+
+ if (!string.IsNullOrWhiteSpace(device.AppVersion))
+ {
+ appVersion = device.AppVersion;
+ }
+ }
+ }
+ else
+ {
+ deviceId = info.DeviceId;
+ }
+
+ // Prevent argument exception
+ if (string.IsNullOrWhiteSpace(appVersion))
+ {
+ appVersion = "1";
+ }
+
+ return LogSessionActivity(appName, appVersion, deviceId, deviceName, remoteEndpoint, user);
+ }
+
+ public Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
+ {
+ var result = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ AccessToken = token
+ });
+
+ var info = result.Items.FirstOrDefault();
+
+ if (info == null)
+ {
+ return Task.FromResult<SessionInfo>(null);
+ }
+
+ return GetSessionByAuthenticationToken(info, deviceId, remoteEndpoint, null);
+ }
+
+ public Task SendMessageToAdminSessions<T>(string name, T data, CancellationToken cancellationToken)
+ {
+ var adminUserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id.ToString("N")).ToList();
+
+ return SendMessageToUserSessions(adminUserIds, name, data, cancellationToken);
+ }
+
+ public Task SendMessageToUserSessions<T>(List<string> userIds, string name, T data,
+ CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null && userIds.Any(i.ContainsUser)).ToList();
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendMessage(name, data, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending message", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ public Task SendMessageToUserDeviceSessions<T>(string deviceId, string name, T data,
+ CancellationToken cancellationToken)
+ {
+ var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null && string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ var tasks = sessions.Select(session => Task.Run(async () =>
+ {
+ try
+ {
+ await session.SessionController.SendMessage(name, data, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending message", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
new file mode 100644
index 000000000..336c2caee
--- /dev/null
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -0,0 +1,485 @@
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Session;
+using System;
+using System.Collections.Specialized;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Services;
+
+namespace Emby.Server.Implementations.Session
+{
+ /// <summary>
+ /// Class SessionWebSocketListener
+ /// </summary>
+ public class SessionWebSocketListener : IWebSocketListener, IDisposable
+ {
+ /// <summary>
+ /// The _true task result
+ /// </summary>
+ private readonly Task _trueTaskResult = Task.FromResult(true);
+
+ /// <summary>
+ /// The _session manager
+ /// </summary>
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// The _dto service
+ /// </summary>
+ private readonly IJsonSerializer _json;
+
+ private readonly IHttpServer _httpServer;
+ private readonly IServerManager _serverManager;
+
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ /// <param name="logManager">The log manager.</param>
+ /// <param name="json">The json.</param>
+ /// <param name="httpServer">The HTTP server.</param>
+ /// <param name="serverManager">The server manager.</param>
+ public SessionWebSocketListener(ISessionManager sessionManager, ILogManager logManager, IJsonSerializer json, IHttpServer httpServer, IServerManager serverManager)
+ {
+ _sessionManager = sessionManager;
+ _logger = logManager.GetLogger(GetType().Name);
+ _json = json;
+ _httpServer = httpServer;
+ _serverManager = serverManager;
+ httpServer.WebSocketConnecting += _httpServer_WebSocketConnecting;
+ serverManager.WebSocketConnected += _serverManager_WebSocketConnected;
+ }
+
+ async void _serverManager_WebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
+ {
+ var session = await GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint).ConfigureAwait(false);
+
+ if (session != null)
+ {
+ var controller = session.SessionController as WebSocketController;
+
+ if (controller == null)
+ {
+ controller = new WebSocketController(session, _logger, _sessionManager);
+ }
+
+ controller.AddWebSocket(e.Argument);
+
+ session.SessionController = controller;
+ }
+ else
+ {
+ _logger.Warn("Unable to determine session based on url: {0}", e.Argument.Url);
+ }
+ }
+
+ async void _httpServer_WebSocketConnecting(object sender, WebSocketConnectingEventArgs e)
+ {
+ //var token = e.QueryString["api_key"];
+ //if (!string.IsNullOrWhiteSpace(token))
+ //{
+ // try
+ // {
+ // var session = await GetSession(e.QueryString, e.Endpoint).ConfigureAwait(false);
+
+ // if (session == null)
+ // {
+ // e.AllowConnection = false;
+ // }
+ // }
+ // catch (Exception ex)
+ // {
+ // _logger.ErrorException("Error getting session info", ex);
+ // }
+ //}
+ }
+
+ private Task<SessionInfo> GetSession(QueryParamCollection queryString, string remoteEndpoint)
+ {
+ if (queryString == null)
+ {
+ throw new ArgumentNullException("queryString");
+ }
+
+ var token = queryString["api_key"];
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return Task.FromResult<SessionInfo>(null);
+ }
+ var deviceId = queryString["deviceId"];
+ return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint);
+ }
+
+ public void Dispose()
+ {
+ _httpServer.WebSocketConnecting -= _httpServer_WebSocketConnecting;
+ _serverManager.WebSocketConnected -= _serverManager_WebSocketConnected;
+ }
+
+ /// <summary>
+ /// Processes the message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ /// <returns>Task.</returns>
+ public Task ProcessMessage(WebSocketMessageInfo message)
+ {
+ if (string.Equals(message.MessageType, "Identity", StringComparison.OrdinalIgnoreCase))
+ {
+ ProcessIdentityMessage(message);
+ }
+ else if (string.Equals(message.MessageType, "Context", StringComparison.OrdinalIgnoreCase))
+ {
+ ProcessContextMessage(message);
+ }
+ else if (string.Equals(message.MessageType, "PlaybackStart", StringComparison.OrdinalIgnoreCase))
+ {
+ OnPlaybackStart(message);
+ }
+ else if (string.Equals(message.MessageType, "PlaybackProgress", StringComparison.OrdinalIgnoreCase))
+ {
+ OnPlaybackProgress(message);
+ }
+ else if (string.Equals(message.MessageType, "PlaybackStopped", StringComparison.OrdinalIgnoreCase))
+ {
+ OnPlaybackStopped(message);
+ }
+ else if (string.Equals(message.MessageType, "ReportPlaybackStart", StringComparison.OrdinalIgnoreCase))
+ {
+ ReportPlaybackStart(message);
+ }
+ else if (string.Equals(message.MessageType, "ReportPlaybackProgress", StringComparison.OrdinalIgnoreCase))
+ {
+ ReportPlaybackProgress(message);
+ }
+ else if (string.Equals(message.MessageType, "ReportPlaybackStopped", StringComparison.OrdinalIgnoreCase))
+ {
+ ReportPlaybackStopped(message);
+ }
+
+ return _trueTaskResult;
+ }
+
+ /// <summary>
+ /// Processes the identity message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private async void ProcessIdentityMessage(WebSocketMessageInfo message)
+ {
+ _logger.Debug("Received Identity message: " + message.Data);
+
+ var vals = message.Data.Split('|');
+
+ if (vals.Length < 3)
+ {
+ _logger.Error("Client sent invalid identity message.");
+ return;
+ }
+
+ var client = vals[0];
+ var deviceId = vals[1];
+ var version = vals[2];
+ var deviceName = vals.Length > 3 ? vals[3] : string.Empty;
+
+ var session = _sessionManager.GetSession(deviceId, client, version);
+
+ if (session == null && !string.IsNullOrEmpty(deviceName))
+ {
+ _logger.Debug("Logging session activity");
+
+ session = await _sessionManager.LogSessionActivity(client, version, deviceId, deviceName, message.Connection.RemoteEndPoint, null).ConfigureAwait(false);
+ }
+
+ if (session != null)
+ {
+ var controller = session.SessionController as WebSocketController;
+
+ if (controller == null)
+ {
+ controller = new WebSocketController(session, _logger, _sessionManager);
+ }
+
+ controller.AddWebSocket(message.Connection);
+
+ session.SessionController = controller;
+ }
+ else
+ {
+ _logger.Warn("Unable to determine session based on identity message: {0}", message.Data);
+ }
+ }
+
+ /// <summary>
+ /// Processes the context message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void ProcessContextMessage(WebSocketMessageInfo message)
+ {
+ var session = GetSessionFromMessage(message);
+
+ if (session != null)
+ {
+ var vals = message.Data.Split('|');
+
+ var itemId = vals[1];
+
+ if (!string.IsNullOrWhiteSpace(itemId))
+ {
+ _sessionManager.ReportNowViewingItem(session.Id, itemId);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the session from message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ /// <returns>SessionInfo.</returns>
+ private SessionInfo GetSessionFromMessage(WebSocketMessageInfo message)
+ {
+ var result = _sessionManager.Sessions.FirstOrDefault(i =>
+ {
+ var controller = i.SessionController as WebSocketController;
+
+ if (controller != null)
+ {
+ if (controller.Sockets.Any(s => s.Id == message.Connection.Id))
+ {
+ return true;
+ }
+ }
+
+ return false;
+
+ });
+
+ if (result == null)
+ {
+ _logger.Error("Unable to find session based on web socket message");
+ }
+
+ return result;
+ }
+
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ /// <summary>
+ /// Reports the playback start.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void OnPlaybackStart(WebSocketMessageInfo message)
+ {
+ _logger.Debug("Received PlaybackStart message");
+
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var vals = message.Data.Split('|');
+
+ var itemId = vals[0];
+
+ var queueableMediaTypes = string.Empty;
+ var canSeek = true;
+
+ if (vals.Length > 1)
+ {
+ canSeek = string.Equals(vals[1], "true", StringComparison.OrdinalIgnoreCase);
+ }
+ if (vals.Length > 2)
+ {
+ queueableMediaTypes = vals[2];
+ }
+
+ var info = new PlaybackStartInfo
+ {
+ CanSeek = canSeek,
+ ItemId = itemId,
+ SessionId = session.Id,
+ QueueableMediaTypes = queueableMediaTypes.Split(',').ToList()
+ };
+
+ if (vals.Length > 3)
+ {
+ info.MediaSourceId = vals[3];
+ }
+
+ if (vals.Length > 4 && !string.IsNullOrWhiteSpace(vals[4]))
+ {
+ info.AudioStreamIndex = int.Parse(vals[4], _usCulture);
+ }
+
+ if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[5]))
+ {
+ info.SubtitleStreamIndex = int.Parse(vals[5], _usCulture);
+ }
+
+ _sessionManager.OnPlaybackStart(info);
+ }
+ }
+
+ private void ReportPlaybackStart(WebSocketMessageInfo message)
+ {
+ _logger.Debug("Received ReportPlaybackStart message");
+
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var info = _json.DeserializeFromString<PlaybackStartInfo>(message.Data);
+
+ info.SessionId = session.Id;
+
+ _sessionManager.OnPlaybackStart(info);
+ }
+ }
+
+ private void ReportPlaybackProgress(WebSocketMessageInfo message)
+ {
+ //_logger.Debug("Received ReportPlaybackProgress message");
+
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var info = _json.DeserializeFromString<PlaybackProgressInfo>(message.Data);
+
+ info.SessionId = session.Id;
+
+ _sessionManager.OnPlaybackProgress(info);
+ }
+ }
+
+ /// <summary>
+ /// Reports the playback progress.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void OnPlaybackProgress(WebSocketMessageInfo message)
+ {
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var vals = message.Data.Split('|');
+
+ var itemId = vals[0];
+
+ long? positionTicks = null;
+
+ if (vals.Length > 1)
+ {
+ long pos;
+
+ if (long.TryParse(vals[1], out pos))
+ {
+ positionTicks = pos;
+ }
+ }
+
+ var isPaused = vals.Length > 2 && string.Equals(vals[2], "true", StringComparison.OrdinalIgnoreCase);
+ var isMuted = vals.Length > 3 && string.Equals(vals[3], "true", StringComparison.OrdinalIgnoreCase);
+
+ var info = new PlaybackProgressInfo
+ {
+ ItemId = itemId,
+ PositionTicks = positionTicks,
+ IsMuted = isMuted,
+ IsPaused = isPaused,
+ SessionId = session.Id
+ };
+
+ if (vals.Length > 4)
+ {
+ info.MediaSourceId = vals[4];
+ }
+
+ if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[5]))
+ {
+ info.VolumeLevel = int.Parse(vals[5], _usCulture);
+ }
+
+ if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[6]))
+ {
+ info.AudioStreamIndex = int.Parse(vals[6], _usCulture);
+ }
+
+ if (vals.Length > 7 && !string.IsNullOrWhiteSpace(vals[7]))
+ {
+ info.SubtitleStreamIndex = int.Parse(vals[7], _usCulture);
+ }
+
+ _sessionManager.OnPlaybackProgress(info);
+ }
+ }
+
+ private void ReportPlaybackStopped(WebSocketMessageInfo message)
+ {
+ _logger.Debug("Received ReportPlaybackStopped message");
+
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var info = _json.DeserializeFromString<PlaybackStopInfo>(message.Data);
+
+ info.SessionId = session.Id;
+
+ _sessionManager.OnPlaybackStopped(info);
+ }
+ }
+
+ /// <summary>
+ /// Reports the playback stopped.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void OnPlaybackStopped(WebSocketMessageInfo message)
+ {
+ _logger.Debug("Received PlaybackStopped message");
+
+ var session = GetSessionFromMessage(message);
+
+ if (session != null && session.UserId.HasValue)
+ {
+ var vals = message.Data.Split('|');
+
+ var itemId = vals[0];
+
+ long? positionTicks = null;
+
+ if (vals.Length > 1)
+ {
+ long pos;
+
+ if (long.TryParse(vals[1], out pos))
+ {
+ positionTicks = pos;
+ }
+ }
+
+ var info = new PlaybackStopInfo
+ {
+ ItemId = itemId,
+ PositionTicks = positionTicks,
+ SessionId = session.Id
+ };
+
+ if (vals.Length > 2)
+ {
+ info.MediaSourceId = vals[2];
+ }
+
+ _sessionManager.OnPlaybackStopped(info);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs
new file mode 100644
index 000000000..f0ff0b5dd
--- /dev/null
+++ b/Emby.Server.Implementations/Session/WebSocketController.cs
@@ -0,0 +1,288 @@
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.System;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Session
+{
+ public class WebSocketController : ISessionController, IDisposable
+ {
+ public SessionInfo Session { get; private set; }
+ public IReadOnlyList<IWebSocketConnection> Sockets { get; private set; }
+
+ private readonly ILogger _logger;
+
+ private readonly ISessionManager _sessionManager;
+
+ public WebSocketController(SessionInfo session, ILogger logger, ISessionManager sessionManager)
+ {
+ Session = session;
+ _logger = logger;
+ _sessionManager = sessionManager;
+ Sockets = new List<IWebSocketConnection>();
+ }
+
+ private bool HasOpenSockets
+ {
+ get { return GetActiveSockets().Any(); }
+ }
+
+ public bool SupportsMediaControl
+ {
+ get { return HasOpenSockets; }
+ }
+
+ private bool _isActive;
+ private DateTime _lastActivityDate;
+ public bool IsSessionActive
+ {
+ get
+ {
+ if (HasOpenSockets)
+ {
+ return true;
+ }
+
+ //return false;
+ return _isActive && (DateTime.UtcNow - _lastActivityDate).TotalMinutes <= 10;
+ }
+ }
+
+ public void OnActivity()
+ {
+ _isActive = true;
+ _lastActivityDate = DateTime.UtcNow;
+ }
+
+ private IEnumerable<IWebSocketConnection> GetActiveSockets()
+ {
+ return Sockets
+ .OrderByDescending(i => i.LastActivityDate)
+ .Where(i => i.State == WebSocketState.Open);
+ }
+
+ public void AddWebSocket(IWebSocketConnection connection)
+ {
+ var sockets = Sockets.ToList();
+ sockets.Add(connection);
+
+ Sockets = sockets;
+
+ connection.Closed += connection_Closed;
+ }
+
+ void connection_Closed(object sender, EventArgs e)
+ {
+ if (!GetActiveSockets().Any())
+ {
+ _isActive = false;
+
+ try
+ {
+ _sessionManager.ReportSessionEnded(Session.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error reporting session ended.", ex);
+ }
+ }
+ }
+
+ private IWebSocketConnection GetActiveSocket()
+ {
+ var socket = GetActiveSockets()
+ .FirstOrDefault();
+
+ if (socket == null)
+ {
+ throw new InvalidOperationException("The requested session does not have an open web socket.");
+ }
+
+ return socket;
+ }
+
+ public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
+ {
+ return SendMessageInternal(new WebSocketMessage<PlayRequest>
+ {
+ MessageType = "Play",
+ Data = command
+
+ }, cancellationToken);
+ }
+
+ public Task SendPlaystateCommand(PlaystateRequest command, CancellationToken cancellationToken)
+ {
+ return SendMessageInternal(new WebSocketMessage<PlaystateRequest>
+ {
+ MessageType = "Playstate",
+ Data = command
+
+ }, cancellationToken);
+ }
+
+ public Task SendLibraryUpdateInfo(LibraryUpdateInfo info, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<LibraryUpdateInfo>
+ {
+ MessageType = "LibraryChanged",
+ Data = info
+
+ }, cancellationToken);
+ }
+
+ /// <summary>
+ /// Sends the restart required message.
+ /// </summary>
+ /// <param name="info">The information.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendRestartRequiredNotification(SystemInfo info, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<SystemInfo>
+ {
+ MessageType = "RestartRequired",
+ Data = info
+
+ }, cancellationToken);
+ }
+
+
+ /// <summary>
+ /// Sends the user data change info.
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendUserDataChangeInfo(UserDataChangeInfo info, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<UserDataChangeInfo>
+ {
+ MessageType = "UserDataChanged",
+ Data = info
+
+ }, cancellationToken);
+ }
+
+ /// <summary>
+ /// Sends the server shutdown notification.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendServerShutdownNotification(CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<string>
+ {
+ MessageType = "ServerShuttingDown",
+ Data = string.Empty
+
+ }, cancellationToken);
+ }
+
+ /// <summary>
+ /// Sends the server restart notification.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task SendServerRestartNotification(CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<string>
+ {
+ MessageType = "ServerRestarting",
+ Data = string.Empty
+
+ }, cancellationToken);
+ }
+
+ public Task SendGeneralCommand(GeneralCommand command, CancellationToken cancellationToken)
+ {
+ return SendMessageInternal(new WebSocketMessage<GeneralCommand>
+ {
+ MessageType = "GeneralCommand",
+ Data = command
+
+ }, cancellationToken);
+ }
+
+ public Task SendSessionEndedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<SessionInfoDto>
+ {
+ MessageType = "SessionEnded",
+ Data = sessionInfo
+
+ }, cancellationToken);
+ }
+
+ public Task SendPlaybackStartNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<SessionInfoDto>
+ {
+ MessageType = "PlaybackStart",
+ Data = sessionInfo
+
+ }, cancellationToken);
+ }
+
+ public Task SendPlaybackStoppedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<SessionInfoDto>
+ {
+ MessageType = "PlaybackStopped",
+ Data = sessionInfo
+
+ }, cancellationToken);
+ }
+
+ public Task SendMessage<T>(string name, T data, CancellationToken cancellationToken)
+ {
+ return SendMessagesInternal(new WebSocketMessage<T>
+ {
+ Data = data,
+ MessageType = name
+
+ }, cancellationToken);
+ }
+
+ private Task SendMessageInternal<T>(WebSocketMessage<T> message, CancellationToken cancellationToken)
+ {
+ var socket = GetActiveSocket();
+
+ return socket.SendAsync(message, cancellationToken);
+ }
+
+ private Task SendMessagesInternal<T>(WebSocketMessage<T> message, CancellationToken cancellationToken)
+ {
+ var tasks = GetActiveSockets().Select(i => Task.Run(async () =>
+ {
+ try
+ {
+ await i.SendAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending web socket message", ex);
+ }
+
+ }, cancellationToken));
+
+ return Task.WhenAll(tasks);
+ }
+
+ public void Dispose()
+ {
+ foreach (var socket in Sockets.ToList())
+ {
+ socket.Closed -= connection_Closed;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Social/SharingManager.cs b/Emby.Server.Implementations/Social/SharingManager.cs
new file mode 100644
index 000000000..54614c879
--- /dev/null
+++ b/Emby.Server.Implementations/Social/SharingManager.cs
@@ -0,0 +1,100 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Social;
+using System;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Social
+{
+ public class SharingManager : ISharingManager
+ {
+ private readonly ISharingRepository _repository;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerApplicationHost _appHost;
+
+ public SharingManager(ISharingRepository repository, IServerConfigurationManager config, ILibraryManager libraryManager, IServerApplicationHost appHost)
+ {
+ _repository = repository;
+ _config = config;
+ _libraryManager = libraryManager;
+ _appHost = appHost;
+ }
+
+ public async Task<SocialShareInfo> CreateShare(string itemId, string userId)
+ {
+ if (string.IsNullOrWhiteSpace(itemId))
+ {
+ throw new ArgumentNullException("itemId");
+ }
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ throw new ArgumentNullException("userId");
+ }
+
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item == null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ var externalUrl = (await _appHost.GetSystemInfo().ConfigureAwait(false)).WanAddress;
+
+ if (string.IsNullOrWhiteSpace(externalUrl))
+ {
+ throw new InvalidOperationException("No external server address is currently available.");
+ }
+
+ var info = new SocialShareInfo
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ ExpirationDate = DateTime.UtcNow.AddDays(_config.Configuration.SharingExpirationDays),
+ ItemId = itemId,
+ UserId = userId
+ };
+
+ AddShareInfo(info, externalUrl);
+
+ await _repository.CreateShare(info).ConfigureAwait(false);
+
+ return info;
+ }
+
+ private string GetTitle(BaseItem item)
+ {
+ return item.Name;
+ }
+
+ public SocialShareInfo GetShareInfo(string id)
+ {
+ var info = _repository.GetShareInfo(id);
+
+ AddShareInfo(info, _appHost.GetSystemInfo().Result.WanAddress);
+
+ return info;
+ }
+
+ private void AddShareInfo(SocialShareInfo info, string externalUrl)
+ {
+ info.ImageUrl = externalUrl + "/Social/Shares/Public/" + info.Id + "/Image";
+ info.Url = externalUrl + "/emby/web/shared.html?id=" + info.Id;
+
+ var item = _libraryManager.GetItemById(info.ItemId);
+
+ if (item != null)
+ {
+ info.Overview = item.Overview;
+ info.Name = GetTitle(item);
+ }
+ }
+
+ public Task DeleteShare(string id)
+ {
+ return _repository.DeleteShare(id);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Social/SharingRepository.cs b/Emby.Server.Implementations/Social/SharingRepository.cs
new file mode 100644
index 000000000..e8230947e
--- /dev/null
+++ b/Emby.Server.Implementations/Social/SharingRepository.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Social;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Social
+{
+ public class SharingRepository : BaseSqliteRepository, ISharingRepository
+ {
+ public SharingRepository(ILogger logger, IApplicationPaths appPaths)
+ : base(logger)
+ {
+ DbFilePath = Path.Combine(appPaths.DataPath, "shares.db");
+ }
+
+ /// <summary>
+ /// Opens the connection to the database
+ /// </summary>
+ /// <returns>Task.</returns>
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists Shares (Id GUID, ItemId TEXT, UserId TEXT, ExpirationDate DateTime, PRIMARY KEY (Id))",
+ "create index if not exists idx_Shares on Shares(Id)",
+
+ "pragma shrink_memory"
+ };
+
+ connection.RunQueries(queries);
+ }
+ }
+
+ public async Task CreateShare(SocialShareInfo info)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException("info");
+ }
+ if (string.IsNullOrWhiteSpace(info.Id))
+ {
+ throw new ArgumentNullException("info.Id");
+ }
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(db =>
+ {
+ var commandText = "replace into Shares (Id, ItemId, UserId, ExpirationDate) values (?, ?, ?, ?)";
+
+ db.Execute(commandText,
+ info.Id.ToGuidParamValue(),
+ info.ItemId,
+ info.UserId,
+ info.ExpirationDate.ToDateTimeParamValue());
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public SocialShareInfo GetShareInfo(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = "select Id, ItemId, UserId, ExpirationDate from Shares where id = ?";
+
+ var paramList = new List<object>();
+ paramList.Add(id.ToGuidParamValue());
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ return GetSocialShareInfo(row);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private SocialShareInfo GetSocialShareInfo(IReadOnlyList<IResultSetValue> reader)
+ {
+ var info = new SocialShareInfo();
+
+ info.Id = reader[0].ReadGuid().ToString("N");
+ info.ItemId = reader[1].ToString();
+ info.UserId = reader[2].ToString();
+ info.ExpirationDate = reader[3].ReadDateTime();
+
+ return info;
+ }
+
+ public async Task DeleteShare(string id)
+ {
+
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/AirTimeComparer.cs b/Emby.Server.Implementations/Sorting/AirTimeComparer.cs
new file mode 100644
index 000000000..bc05e9af3
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/AirTimeComparer.cs
@@ -0,0 +1,71 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class AirTimeComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return DateTime.Compare(GetValue(x), GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private DateTime GetValue(BaseItem x)
+ {
+ var series = x as Series;
+
+ if (series == null)
+ {
+ var season = x as Season;
+
+ if (season != null)
+ {
+ series = season.Series;
+ }
+ else
+ {
+ var episode = x as Episode;
+
+ if (episode != null)
+ {
+ series = episode.Series;
+ }
+ }
+ }
+
+ if (series != null)
+ {
+ DateTime result;
+ if (DateTime.TryParse(series.AirTime, out result))
+ {
+ return result;
+ }
+ }
+
+ return DateTime.MinValue;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.AirTime; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs
new file mode 100644
index 000000000..494668cb9
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs
@@ -0,0 +1,160 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ class AiredEpisodeOrderComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ if (x.PremiereDate.HasValue && y.PremiereDate.HasValue)
+ {
+ var val = DateTime.Compare(x.PremiereDate.Value, y.PremiereDate.Value);
+
+ if (val != 0)
+ {
+ //return val;
+ }
+ }
+
+ var episode1 = x as Episode;
+ var episode2 = y as Episode;
+
+ if (episode1 == null)
+ {
+ if (episode2 == null)
+ {
+ return 0;
+ }
+
+ return 1;
+ }
+
+ if (episode2 == null)
+ {
+ return -1;
+ }
+
+ return Compare(episode1, episode2);
+ }
+
+ private int Compare(Episode x, Episode y)
+ {
+ var isXSpecial = (x.ParentIndexNumber ?? -1) == 0;
+ var isYSpecial = (y.ParentIndexNumber ?? -1) == 0;
+
+ if (isXSpecial && isYSpecial)
+ {
+ return CompareSpecials(x, y);
+ }
+
+ if (!isXSpecial && !isYSpecial)
+ {
+ return CompareEpisodes(x, y);
+ }
+
+ if (!isXSpecial)
+ {
+ return CompareEpisodeToSpecial(x, y);
+ }
+
+ return CompareEpisodeToSpecial(y, x) * -1;
+ }
+
+ private int CompareEpisodeToSpecial(Episode x, Episode y)
+ {
+ // http://thetvdb.com/wiki/index.php?title=Special_Episodes
+
+ var xSeason = x.ParentIndexNumber ?? -1;
+ var ySeason = y.AirsAfterSeasonNumber ?? y.AirsBeforeSeasonNumber ?? -1;
+
+ if (xSeason != ySeason)
+ {
+ return xSeason.CompareTo(ySeason);
+ }
+
+ // Special comes after episode
+ if (y.AirsAfterSeasonNumber.HasValue)
+ {
+ return -1;
+ }
+
+ var yEpisode = y.AirsBeforeEpisodeNumber;
+
+ // Special comes before the season
+ if (!yEpisode.HasValue)
+ {
+ return 1;
+ }
+
+ // Compare episode number
+ var xEpisode = x.IndexNumber;
+
+ if (!xEpisode.HasValue)
+ {
+ // Can't really compare if this happens
+ return 0;
+ }
+
+ // Special comes before episode
+ if (xEpisode.Value == yEpisode.Value)
+ {
+ return 1;
+ }
+
+ return xEpisode.Value.CompareTo(yEpisode.Value);
+ }
+
+ private int CompareSpecials(Episode x, Episode y)
+ {
+ return GetSpecialCompareValue(x).CompareTo(GetSpecialCompareValue(y));
+ }
+
+ private int GetSpecialCompareValue(Episode item)
+ {
+ // First sort by season number
+ // Since there are three sort orders, pad with 9 digits (3 for each, figure 1000 episode buffer should be enough)
+ var val = (item.AirsAfterSeasonNumber ?? item.AirsBeforeSeasonNumber ?? 0) * 1000000000;
+
+ // Second sort order is if it airs after the season
+ if (item.AirsAfterSeasonNumber.HasValue)
+ {
+ val += 1000000;
+ }
+
+ // Third level is the episode number
+ val += (item.AirsBeforeEpisodeNumber ?? 0) * 1000;
+
+ // Finally, if that's still the same, last resort is the special number itself
+ val += item.IndexNumber ?? 0;
+
+ return val;
+ }
+
+ private int CompareEpisodes(Episode x, Episode y)
+ {
+ var xValue = (x.ParentIndexNumber ?? -1) * 1000 + (x.IndexNumber ?? -1);
+ var yValue = (y.ParentIndexNumber ?? -1) * 1000 + (y.IndexNumber ?? -1);
+
+ return xValue.CompareTo(yValue);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.AiredEpisodeOrder; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs
new file mode 100644
index 000000000..cd3834080
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs
@@ -0,0 +1,47 @@
+using System.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class AlbumArtistComparer
+ /// </summary>
+ public class AlbumArtistComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private string GetValue(BaseItem x)
+ {
+ var audio = x as IHasAlbumArtist;
+
+ return audio != null ? audio.AlbumArtists.FirstOrDefault() : null;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.AlbumArtist; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/AlbumComparer.cs b/Emby.Server.Implementations/Sorting/AlbumComparer.cs
new file mode 100644
index 000000000..68f5f173e
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/AlbumComparer.cs
@@ -0,0 +1,46 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class AlbumComparer
+ /// </summary>
+ public class AlbumComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private string GetValue(BaseItem x)
+ {
+ var audio = x as Audio;
+
+ return audio == null ? string.Empty : audio.Album;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Album; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/AlphanumComparator.cs b/Emby.Server.Implementations/Sorting/AlphanumComparator.cs
new file mode 100644
index 000000000..4bfcda1ac
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/AlphanumComparator.cs
@@ -0,0 +1,99 @@
+using System.Collections.Generic;
+using System.Text;
+using MediaBrowser.Controller.Sorting;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class AlphanumComparator : IComparer<string>
+ {
+ public static int CompareValues(string s1, string s2)
+ {
+ if (s1 == null || s2 == null)
+ {
+ return 0;
+ }
+
+ int thisMarker = 0, thisNumericChunk = 0;
+ int thatMarker = 0, thatNumericChunk = 0;
+
+ while ((thisMarker < s1.Length) || (thatMarker < s2.Length))
+ {
+ if (thisMarker >= s1.Length)
+ {
+ return -1;
+ }
+ else if (thatMarker >= s2.Length)
+ {
+ return 1;
+ }
+ char thisCh = s1[thisMarker];
+ char thatCh = s2[thatMarker];
+
+ StringBuilder thisChunk = new StringBuilder();
+ StringBuilder thatChunk = new StringBuilder();
+
+ while ((thisMarker < s1.Length) && (thisChunk.Length == 0 || SortHelper.InChunk(thisCh, thisChunk[0])))
+ {
+ thisChunk.Append(thisCh);
+ thisMarker++;
+
+ if (thisMarker < s1.Length)
+ {
+ thisCh = s1[thisMarker];
+ }
+ }
+
+ while ((thatMarker < s2.Length) && (thatChunk.Length == 0 || SortHelper.InChunk(thatCh, thatChunk[0])))
+ {
+ thatChunk.Append(thatCh);
+ thatMarker++;
+
+ if (thatMarker < s2.Length)
+ {
+ thatCh = s2[thatMarker];
+ }
+ }
+
+ int result = 0;
+ // If both chunks contain numeric characters, sort them numerically
+ if (char.IsDigit(thisChunk[0]) && char.IsDigit(thatChunk[0]))
+ {
+ if (!int.TryParse(thisChunk.ToString(), out thisNumericChunk))
+ {
+ return 0;
+ }
+ if (!int.TryParse(thatChunk.ToString(), out thatNumericChunk))
+ {
+ return 0;
+ }
+
+ if (thisNumericChunk < thatNumericChunk)
+ {
+ result = -1;
+ }
+
+ if (thisNumericChunk > thatNumericChunk)
+ {
+ result = 1;
+ }
+ }
+ else
+ {
+ result = thisChunk.ToString().CompareTo(thatChunk.ToString());
+ }
+
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+
+ return 0;
+ }
+
+ public int Compare(string x, string y)
+ {
+ return CompareValues(x, y);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/ArtistComparer.cs b/Emby.Server.Implementations/Sorting/ArtistComparer.cs
new file mode 100644
index 000000000..edb195820
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs
@@ -0,0 +1,51 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class ArtistComparer
+ /// </summary>
+ public class ArtistComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private string GetValue(BaseItem x)
+ {
+ var audio = x as Audio;
+
+ if (audio == null)
+ {
+ return string.Empty;
+ }
+
+ return audio.Artists.Count == 0 ? null : audio.Artists[0];
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Artist; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/BudgetComparer.cs b/Emby.Server.Implementations/Sorting/BudgetComparer.cs
new file mode 100644
index 000000000..f3aef69f1
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/BudgetComparer.cs
@@ -0,0 +1,39 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class BudgetComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ private double GetValue(BaseItem x)
+ {
+ var hasBudget = x as IHasBudget;
+ if (hasBudget != null)
+ {
+ return hasBudget.Budget ?? 0;
+ }
+ return 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Budget; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs
new file mode 100644
index 000000000..396bbbdb9
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class CommunityRatingComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return (x.CommunityRating ?? 0).CompareTo(y.CommunityRating ?? 0);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.CommunityRating; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs
new file mode 100644
index 000000000..877dbfcc1
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs
@@ -0,0 +1,37 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class CriticRatingComparer
+ /// </summary>
+ public class CriticRatingComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ private float GetValue(BaseItem x)
+ {
+ return x.CriticRating ?? 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.CriticRating; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs
new file mode 100644
index 000000000..c436fcb4a
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class DateCreatedComparer
+ /// </summary>
+ public class DateCreatedComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return DateTime.Compare(x.DateCreated, y.DateCreated);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.DateCreated; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
new file mode 100644
index 000000000..fc92505ac
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
@@ -0,0 +1,69 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class DateLastMediaAddedComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetDate(x).CompareTo(GetDate(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private DateTime GetDate(BaseItem x)
+ {
+ var folder = x as Folder;
+
+ if (folder != null)
+ {
+ if (folder.DateLastMediaAdded.HasValue)
+ {
+ return folder.DateLastMediaAdded.Value;
+ }
+ }
+
+ return DateTime.MinValue;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.DateLastContentAdded; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
new file mode 100644
index 000000000..388d2772e
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
@@ -0,0 +1,69 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class DatePlayedComparer
+ /// </summary>
+ public class DatePlayedComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetDate(x).CompareTo(GetDate(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private DateTime GetDate(BaseItem x)
+ {
+ var userdata = UserDataRepository.GetUserData(User, x);
+
+ if (userdata != null && userdata.LastPlayedDate.HasValue)
+ {
+ return userdata.LastPlayedDate.Value;
+ }
+
+ return DateTime.MinValue;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.DatePlayed; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/GameSystemComparer.cs b/Emby.Server.Implementations/Sorting/GameSystemComparer.cs
new file mode 100644
index 000000000..4ee30397d
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/GameSystemComparer.cs
@@ -0,0 +1,54 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class GameSystemComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private string GetValue(BaseItem x)
+ {
+ var game = x as Game;
+
+ if (game != null)
+ {
+ return game.GameSystem;
+ }
+
+ var system = x as GameSystem;
+
+ if (system != null)
+ {
+ return system.GameSystemName;
+ }
+
+ return string.Empty;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.GameSystem; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
new file mode 100644
index 000000000..27485f09e
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
@@ -0,0 +1,58 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class IsFavoriteOrLikeComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private int GetValue(BaseItem x)
+ {
+ return x.IsFavoriteOrLiked(User) ? 0 : 1;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.IsFavoriteOrLiked; }
+ }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs
new file mode 100644
index 000000000..756d13bd8
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs
@@ -0,0 +1,39 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class IsFolderComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private int GetValue(BaseItem x)
+ {
+ return x.IsFolder ? 0 : 1;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.IsFolder; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
new file mode 100644
index 000000000..987dc54a5
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
@@ -0,0 +1,58 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class IsPlayedComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private int GetValue(BaseItem x)
+ {
+ return x.IsPlayed(User) ? 0 : 1;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.IsUnplayed; }
+ }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
new file mode 100644
index 000000000..0f4e4c37e
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
@@ -0,0 +1,58 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class IsUnplayedComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private int GetValue(BaseItem x)
+ {
+ return x.IsUnplayed(User) ? 0 : 1;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.IsUnplayed; }
+ }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/MetascoreComparer.cs b/Emby.Server.Implementations/Sorting/MetascoreComparer.cs
new file mode 100644
index 000000000..9759e0228
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/MetascoreComparer.cs
@@ -0,0 +1,41 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class MetascoreComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ private float GetValue(BaseItem x)
+ {
+ var hasMetascore = x as IHasMetascore;
+
+ if (hasMetascore != null)
+ {
+ return hasMetascore.Metascore ?? 0;
+ }
+
+ return 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Metascore; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/NameComparer.cs b/Emby.Server.Implementations/Sorting/NameComparer.cs
new file mode 100644
index 000000000..8ab5e5172
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/NameComparer.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class NameComparer
+ /// </summary>
+ public class NameComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Name; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs
new file mode 100644
index 000000000..3eab4fccc
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs
@@ -0,0 +1,40 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class OfficialRatingComparer : IBaseItemComparer
+ {
+ private readonly ILocalizationManager _localization;
+
+ public OfficialRatingComparer(ILocalizationManager localization)
+ {
+ _localization = localization;
+ }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ var levelX = string.IsNullOrEmpty(x.OfficialRating) ? 0 : _localization.GetRatingLevel(x.OfficialRating) ?? 0;
+ var levelY = string.IsNullOrEmpty(y.OfficialRating) ? 0 : _localization.GetRatingLevel(y.OfficialRating) ?? 0;
+
+ return levelX.CompareTo(levelY);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.OfficialRating; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs
new file mode 100644
index 000000000..aecad7c58
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs
@@ -0,0 +1,63 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class PlayCountComparer
+ /// </summary>
+ public class PlayCountComparer : IUserBaseItemComparer
+ {
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public User User { get; set; }
+
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private int GetValue(BaseItem x)
+ {
+ var userdata = UserDataRepository.GetUserData(User, x);
+
+ return userdata == null ? 0 : userdata.PlayCount;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.PlayCount; }
+ }
+
+ /// <summary>
+ /// Gets or sets the user data repository.
+ /// </summary>
+ /// <value>The user data repository.</value>
+ public IUserDataManager UserDataRepository { get; set; }
+
+ /// <summary>
+ /// Gets or sets the user manager.
+ /// </summary>
+ /// <value>The user manager.</value>
+ public IUserManager UserManager { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/PlayersComparer.cs b/Emby.Server.Implementations/Sorting/PlayersComparer.cs
new file mode 100644
index 000000000..3b54517c3
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/PlayersComparer.cs
@@ -0,0 +1,46 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class PlayersComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the value.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>System.String.</returns>
+ private int GetValue(BaseItem x)
+ {
+ var game = x as Game;
+
+ if (game != null)
+ {
+ return game.PlayersSupported ?? 0;
+ }
+
+ return 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Players; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs
new file mode 100644
index 000000000..d7219c86f
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs
@@ -0,0 +1,59 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class PremiereDateComparer
+ /// </summary>
+ public class PremiereDateComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetDate(x).CompareTo(GetDate(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private DateTime GetDate(BaseItem x)
+ {
+ if (x.PremiereDate.HasValue)
+ {
+ return x.PremiereDate.Value;
+ }
+
+ if (x.ProductionYear.HasValue)
+ {
+ try
+ {
+ return new DateTime(x.ProductionYear.Value, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ // Don't blow up if the item has a bad ProductionYear, just return MinValue
+ }
+ }
+ return DateTime.MinValue;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.PremiereDate; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs
new file mode 100644
index 000000000..ea479419a
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs
@@ -0,0 +1,52 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class ProductionYearComparer
+ /// </summary>
+ public class ProductionYearComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private int GetValue(BaseItem x)
+ {
+ if (x.ProductionYear.HasValue)
+ {
+ return x.ProductionYear.Value;
+ }
+
+ if (x.PremiereDate.HasValue)
+ {
+ return x.PremiereDate.Value.Year;
+ }
+
+ return 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.ProductionYear; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/RandomComparer.cs b/Emby.Server.Implementations/Sorting/RandomComparer.cs
new file mode 100644
index 000000000..1fbecde56
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/RandomComparer.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class RandomComparer
+ /// </summary>
+ public class RandomComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return Guid.NewGuid().CompareTo(Guid.NewGuid());
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Random; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/RevenueComparer.cs b/Emby.Server.Implementations/Sorting/RevenueComparer.cs
new file mode 100644
index 000000000..62e43eac1
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/RevenueComparer.cs
@@ -0,0 +1,39 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class RevenueComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetValue(x).CompareTo(GetValue(y));
+ }
+
+ private double GetValue(BaseItem x)
+ {
+ var hasBudget = x as IHasBudget;
+ if (hasBudget != null)
+ {
+ return hasBudget.Revenue ?? 0;
+ }
+ return 0;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Revenue; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs
new file mode 100644
index 000000000..63c4758cb
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs
@@ -0,0 +1,32 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class RuntimeComparer
+ /// </summary>
+ public class RuntimeComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return (x.RunTimeTicks ?? 0).CompareTo(y.RunTimeTicks ?? 0);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Runtime; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs
new file mode 100644
index 000000000..b315d33c3
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs
@@ -0,0 +1,37 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ class SeriesSortNameComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ private string GetValue(BaseItem item)
+ {
+ var hasSeries = item as IHasSeries;
+
+ return hasSeries != null ? hasSeries.SeriesSortName : null;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.SeriesSortName; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs
new file mode 100644
index 000000000..f2a764840
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/SortNameComparer.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ /// <summary>
+ /// Class SortNameComparer
+ /// </summary>
+ public class SortNameComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return string.Compare(x.SortName, y.SortName, StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.SortName; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs
new file mode 100644
index 000000000..6be5f4883
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs
@@ -0,0 +1,47 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class StartDateComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return GetDate(x).CompareTo(GetDate(y));
+ }
+
+ /// <summary>
+ /// Gets the date.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <returns>DateTime.</returns>
+ private DateTime GetDate(BaseItem x)
+ {
+ var hasStartDate = x as LiveTvProgram;
+
+ if (hasStartDate != null)
+ {
+ return hasStartDate.StartDate;
+ }
+ return DateTime.MinValue;
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.StartDate; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs
new file mode 100644
index 000000000..6735022af
--- /dev/null
+++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs
@@ -0,0 +1,30 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Querying;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Sorting
+{
+ public class StudioComparer : IBaseItemComparer
+ {
+ /// <summary>
+ /// Compares the specified x.
+ /// </summary>
+ /// <param name="x">The x.</param>
+ /// <param name="y">The y.</param>
+ /// <returns>System.Int32.</returns>
+ public int Compare(BaseItem x, BaseItem y)
+ {
+ return AlphanumComparator.CompareValues(x.Studios.FirstOrDefault() ?? string.Empty, y.Studios.FirstOrDefault() ?? string.Empty);
+ }
+
+ /// <summary>
+ /// Gets the name.
+ /// </summary>
+ /// <value>The name.</value>
+ public string Name
+ {
+ get { return ItemSortBy.Studio; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/StartupOptions.cs b/Emby.Server.Implementations/StartupOptions.cs
new file mode 100644
index 000000000..159c36248
--- /dev/null
+++ b/Emby.Server.Implementations/StartupOptions.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Emby.Server.Implementations
+{
+ public class StartupOptions
+ {
+ private readonly List<string> _options;
+
+ public StartupOptions(string[] commandLineArgs)
+ {
+ _options = commandLineArgs.ToList();
+ }
+
+ public bool ContainsOption(string option)
+ {
+ return _options.Contains(option, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public string GetOption(string name)
+ {
+ var index = _options.IndexOf(name);
+
+ if (index != -1)
+ {
+ return _options.ElementAtOrDefault(index + 1);
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/AppSyncProvider.cs b/Emby.Server.Implementations/Sync/AppSyncProvider.cs
new file mode 100644
index 000000000..d405a0ff9
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/AppSyncProvider.cs
@@ -0,0 +1,118 @@
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class AppSyncProvider : ISyncProvider, IHasUniqueTargetIds, IHasSyncQuality, IHasDuplicateCheck
+ {
+ private readonly IDeviceManager _deviceManager;
+
+ public AppSyncProvider(IDeviceManager deviceManager)
+ {
+ _deviceManager = deviceManager;
+ }
+
+ public IEnumerable<SyncTarget> GetSyncTargets(string userId)
+ {
+ return _deviceManager.GetDevices(new DeviceQuery
+ {
+ SupportsSync = true,
+ UserId = userId
+
+ }).Items.Select(i => new SyncTarget
+ {
+ Id = i.Id,
+ Name = i.Name
+ });
+ }
+
+ public DeviceProfile GetDeviceProfile(SyncTarget target, string profile, string quality)
+ {
+ var caps = _deviceManager.GetCapabilities(target.Id);
+
+ var deviceProfile = caps == null || caps.DeviceProfile == null ? new DeviceProfile() : caps.DeviceProfile;
+ deviceProfile.MaxStaticBitrate = SyncHelper.AdjustBitrate(deviceProfile.MaxStaticBitrate, quality);
+
+ return deviceProfile;
+ }
+
+ public string Name
+ {
+ get { return "Mobile Sync"; }
+ }
+
+ public IEnumerable<SyncTarget> GetAllSyncTargets()
+ {
+ return _deviceManager.GetDevices(new DeviceQuery
+ {
+ SupportsSync = true
+
+ }).Items.Select(i => new SyncTarget
+ {
+ Id = i.Id,
+ Name = i.Name
+ });
+ }
+
+ public IEnumerable<SyncQualityOption> GetQualityOptions(SyncTarget target)
+ {
+ return new List<SyncQualityOption>
+ {
+ new SyncQualityOption
+ {
+ Name = "Original",
+ Id = "original",
+ Description = "Syncs original files as-is, regardless of whether the device is capable of playing them or not."
+ },
+ new SyncQualityOption
+ {
+ Name = "High",
+ Id = "high",
+ IsDefault = true
+ },
+ new SyncQualityOption
+ {
+ Name = "Medium",
+ Id = "medium"
+ },
+ new SyncQualityOption
+ {
+ Name = "Low",
+ Id = "low"
+ },
+ new SyncQualityOption
+ {
+ Name = "Custom",
+ Id = "custom"
+ }
+ };
+ }
+
+ public IEnumerable<SyncProfileOption> GetProfileOptions(SyncTarget target)
+ {
+ return new List<SyncProfileOption>();
+ }
+
+ public SyncJobOptions GetSyncJobOptions(SyncTarget target, string profile, string quality)
+ {
+ var isConverting = !string.Equals(quality, "original", StringComparison.OrdinalIgnoreCase);
+
+ return new SyncJobOptions
+ {
+ DeviceProfile = GetDeviceProfile(target, profile, quality),
+ IsConverting = isConverting
+ };
+ }
+
+ public bool AllowDuplicateJobItem(SyncJobItem original, SyncJobItem duplicate)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/CloudSyncProfile.cs b/Emby.Server.Implementations/Sync/CloudSyncProfile.cs
new file mode 100644
index 000000000..1a78c8ae6
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/CloudSyncProfile.cs
@@ -0,0 +1,302 @@
+using MediaBrowser.Model.Dlna;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class CloudSyncProfile : DeviceProfile
+ {
+ public CloudSyncProfile(bool supportsAc3, bool supportsDca)
+ {
+ Name = "Cloud Sync";
+
+ MaxStreamingBitrate = 20000000;
+ MaxStaticBitrate = 20000000;
+
+ var mkvAudio = "aac,mp3";
+ var mp4Audio = "aac";
+
+ if (supportsAc3)
+ {
+ mkvAudio += ",ac3";
+ mp4Audio += ",ac3";
+ }
+
+ if (supportsDca)
+ {
+ mkvAudio += ",dca,dts";
+ }
+
+ var videoProfile = "high|main|baseline|constrained baseline";
+ var videoLevel = "40";
+
+ DirectPlayProfiles = new[]
+ {
+ //new DirectPlayProfile
+ //{
+ // Container = "mkv",
+ // VideoCodec = "h264,mpeg4",
+ // AudioCodec = mkvAudio,
+ // Type = DlnaProfileType.Video
+ //},
+ new DirectPlayProfile
+ {
+ Container = "mp4,mov,m4v",
+ VideoCodec = "h264,mpeg4",
+ AudioCodec = mp4Audio,
+ Type = DlnaProfileType.Video
+ },
+ new DirectPlayProfile
+ {
+ Container = "mp3",
+ Type = DlnaProfileType.Audio
+ }
+ };
+
+ ContainerProfiles = new[]
+ {
+ new ContainerProfile
+ {
+ Type = DlnaProfileType.Video,
+ Conditions = new []
+ {
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.NotEquals,
+ Property = ProfileConditionValue.NumAudioStreams,
+ Value = "0",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.EqualsAny,
+ Property = ProfileConditionValue.NumVideoStreams,
+ Value = "1",
+ IsRequired = false
+ }
+ }
+ }
+ };
+
+ var codecProfiles = new List<CodecProfile>
+ {
+ new CodecProfile
+ {
+ Type = CodecType.Video,
+ Codec = "h264",
+ Conditions = new []
+ {
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.VideoBitDepth,
+ Value = "8",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.Width,
+ Value = "1920",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.Height,
+ Value = "1080",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.RefFrames,
+ Value = "4",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.VideoFramerate,
+ Value = "30",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.Equals,
+ Property = ProfileConditionValue.IsAnamorphic,
+ Value = "false",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.VideoLevel,
+ Value = videoLevel,
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.EqualsAny,
+ Property = ProfileConditionValue.VideoProfile,
+ Value = videoProfile,
+ IsRequired = false
+ }
+ }
+ },
+ new CodecProfile
+ {
+ Type = CodecType.Video,
+ Codec = "mpeg4",
+ Conditions = new []
+ {
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.VideoBitDepth,
+ Value = "8",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.Width,
+ Value = "1920",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.Height,
+ Value = "1080",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.RefFrames,
+ Value = "4",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.VideoFramerate,
+ Value = "30",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.Equals,
+ Property = ProfileConditionValue.IsAnamorphic,
+ Value = "false",
+ IsRequired = false
+ }
+ }
+ }
+ };
+
+ codecProfiles.Add(new CodecProfile
+ {
+ Type = CodecType.VideoAudio,
+ Codec = "ac3",
+ Conditions = new[]
+ {
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.AudioChannels,
+ Value = "6",
+ IsRequired = false
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.AudioBitrate,
+ Value = "320000",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.Equals,
+ Property = ProfileConditionValue.IsSecondaryAudio,
+ Value = "false",
+ IsRequired = false
+ }
+ }
+ });
+ codecProfiles.Add(new CodecProfile
+ {
+ Type = CodecType.VideoAudio,
+ Codec = "aac,mp3",
+ Conditions = new[]
+ {
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.AudioChannels,
+ Value = "2",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.LessThanEqual,
+ Property = ProfileConditionValue.AudioBitrate,
+ Value = "320000",
+ IsRequired = true
+ },
+ new ProfileCondition
+ {
+ Condition = ProfileConditionType.Equals,
+ Property = ProfileConditionValue.IsSecondaryAudio,
+ Value = "false",
+ IsRequired = false
+ }
+ }
+ });
+
+ CodecProfiles = codecProfiles.ToArray();
+
+ SubtitleProfiles = new[]
+ {
+ new SubtitleProfile
+ {
+ Format = "srt",
+ Method = SubtitleDeliveryMethod.External
+ },
+ new SubtitleProfile
+ {
+ Format = "vtt",
+ Method = SubtitleDeliveryMethod.External
+ }
+ };
+
+ TranscodingProfiles = new[]
+ {
+ new TranscodingProfile
+ {
+ Container = "mp3",
+ AudioCodec = "mp3",
+ Type = DlnaProfileType.Audio,
+ Context = EncodingContext.Static
+ },
+
+ new TranscodingProfile
+ {
+ Container = "mp4",
+ Type = DlnaProfileType.Video,
+ AudioCodec = "aac",
+ VideoCodec = "h264",
+ Context = EncodingContext.Static
+ },
+
+ new TranscodingProfile
+ {
+ Container = "jpeg",
+ Type = DlnaProfileType.Photo,
+ Context = EncodingContext.Static
+ }
+ };
+
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/IHasSyncQuality.cs b/Emby.Server.Implementations/Sync/IHasSyncQuality.cs
new file mode 100644
index 000000000..bec8b37a7
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/IHasSyncQuality.cs
@@ -0,0 +1,31 @@
+using MediaBrowser.Model.Sync;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public interface IHasSyncQuality
+ {
+ /// <summary>
+ /// Gets the device profile.
+ /// </summary>
+ /// <param name="target">The target.</param>
+ /// <param name="profile">The profile.</param>
+ /// <param name="quality">The quality.</param>
+ /// <returns>DeviceProfile.</returns>
+ SyncJobOptions GetSyncJobOptions(SyncTarget target, string profile, string quality);
+
+ /// <summary>
+ /// Gets the quality options.
+ /// </summary>
+ /// <param name="target">The target.</param>
+ /// <returns>IEnumerable&lt;SyncQualityOption&gt;.</returns>
+ IEnumerable<SyncQualityOption> GetQualityOptions(SyncTarget target);
+
+ /// <summary>
+ /// Gets the profile options.
+ /// </summary>
+ /// <param name="target">The target.</param>
+ /// <returns>IEnumerable&lt;SyncQualityOption&gt;.</returns>
+ IEnumerable<SyncProfileOption> GetProfileOptions(SyncTarget target);
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/MediaSync.cs b/Emby.Server.Implementations/Sync/MediaSync.cs
new file mode 100644
index 000000000..fa8388b6c
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/MediaSync.cs
@@ -0,0 +1,500 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.IO;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class MediaSync
+ {
+ private readonly ISyncManager _syncManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IConfigurationManager _config;
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ public const string PathSeparatorString = "/";
+ public const char PathSeparatorChar = '/';
+
+ public MediaSync(ILogger logger, ISyncManager syncManager, IServerApplicationHost appHost, IFileSystem fileSystem, IConfigurationManager config, ICryptoProvider cryptographyProvider)
+ {
+ _logger = logger;
+ _syncManager = syncManager;
+ _appHost = appHost;
+ _fileSystem = fileSystem;
+ _config = config;
+ _cryptographyProvider = cryptographyProvider;
+ }
+
+ public async Task Sync(IServerSyncProvider provider,
+ ISyncDataProvider dataProvider,
+ SyncTarget target,
+ IProgress<double> progress,
+ CancellationToken cancellationToken)
+ {
+ var serverId = _appHost.SystemId;
+ var serverName = _appHost.FriendlyName;
+
+ await SyncData(provider, dataProvider, serverId, target, cancellationToken).ConfigureAwait(false);
+ progress.Report(3);
+
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(pct =>
+ {
+ var totalProgress = pct * .97;
+ totalProgress += 1;
+ progress.Report(totalProgress);
+ });
+ await GetNewMedia(provider, dataProvider, target, serverId, serverName, innerProgress, cancellationToken);
+
+ // Do the data sync twice so the server knows what was removed from the device
+ await SyncData(provider, dataProvider, serverId, target, cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ private async Task SyncData(IServerSyncProvider provider,
+ ISyncDataProvider dataProvider,
+ string serverId,
+ SyncTarget target,
+ CancellationToken cancellationToken)
+ {
+ var localItems = await dataProvider.GetLocalItems(target, serverId).ConfigureAwait(false);
+ var remoteFiles = await provider.GetFiles(target, cancellationToken).ConfigureAwait(false);
+ var remoteIds = remoteFiles.Items.Select(i => i.FullName).ToList();
+
+ var jobItemIds = new List<string>();
+
+ foreach (var localItem in localItems)
+ {
+ if (remoteIds.Contains(localItem.FileId, StringComparer.OrdinalIgnoreCase))
+ {
+ jobItemIds.Add(localItem.SyncJobItemId);
+ }
+ }
+
+ var result = await _syncManager.SyncData(new SyncDataRequest
+ {
+ TargetId = target.Id,
+ SyncJobItemIds = jobItemIds
+
+ }).ConfigureAwait(false);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var itemIdToRemove in result.ItemIdsToRemove)
+ {
+ try
+ {
+ await RemoveItem(provider, dataProvider, serverId, itemIdToRemove, target, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting item from device. Id: {0}", ex, itemIdToRemove);
+ }
+ }
+ }
+
+ private async Task GetNewMedia(IServerSyncProvider provider,
+ ISyncDataProvider dataProvider,
+ SyncTarget target,
+ string serverId,
+ string serverName,
+ IProgress<double> progress,
+ CancellationToken cancellationToken)
+ {
+ var jobItems = await _syncManager.GetReadySyncItems(target.Id).ConfigureAwait(false);
+
+ var numComplete = 0;
+ double startingPercent = 0;
+ double percentPerItem = 1;
+ if (jobItems.Count > 0)
+ {
+ percentPerItem /= jobItems.Count;
+ }
+
+ foreach (var jobItem in jobItems)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var currentPercent = startingPercent;
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(pct =>
+ {
+ var totalProgress = pct * percentPerItem;
+ totalProgress += currentPercent;
+ progress.Report(totalProgress);
+ });
+
+ try
+ {
+ await GetItem(provider, dataProvider, target, serverId, serverName, jobItem, innerProgress, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error syncing item", ex);
+ }
+
+ numComplete++;
+ startingPercent = numComplete;
+ startingPercent /= jobItems.Count;
+ startingPercent *= 100;
+ progress.Report(startingPercent);
+ }
+ }
+
+ private async Task GetItem(IServerSyncProvider provider,
+ ISyncDataProvider dataProvider,
+ SyncTarget target,
+ string serverId,
+ string serverName,
+ SyncedItem jobItem,
+ IProgress<double> progress,
+ CancellationToken cancellationToken)
+ {
+ var libraryItem = jobItem.Item;
+ var internalSyncJobItem = _syncManager.GetJobItem(jobItem.SyncJobItemId);
+ var internalSyncJob = _syncManager.GetJob(jobItem.SyncJobId);
+
+ var localItem = CreateLocalItem(provider, jobItem, internalSyncJob, target, libraryItem, serverId, serverName, jobItem.OriginalFileName);
+
+ await _syncManager.ReportSyncJobItemTransferBeginning(internalSyncJobItem.Id);
+
+ var transferSuccess = false;
+ Exception transferException = null;
+
+ var options = _config.GetSyncOptions();
+
+ try
+ {
+ var fileTransferProgress = new ActionableProgress<double>();
+ fileTransferProgress.RegisterAction(pct => progress.Report(pct * .92));
+
+ var sendFileResult = await SendFile(provider, internalSyncJobItem.OutputPath, localItem.LocalPath.Split(PathSeparatorChar), target, options, fileTransferProgress, cancellationToken).ConfigureAwait(false);
+
+ if (localItem.Item.MediaSources != null)
+ {
+ var mediaSource = localItem.Item.MediaSources.FirstOrDefault();
+ if (mediaSource != null)
+ {
+ mediaSource.Path = sendFileResult.Path;
+ mediaSource.Protocol = sendFileResult.Protocol;
+ mediaSource.RequiredHttpHeaders = sendFileResult.RequiredHttpHeaders;
+ mediaSource.SupportsTranscoding = false;
+ }
+ }
+
+ localItem.FileId = sendFileResult.Id;
+
+ // Create db record
+ await dataProvider.AddOrUpdate(target, localItem).ConfigureAwait(false);
+
+ if (localItem.Item.MediaSources != null)
+ {
+ var mediaSource = localItem.Item.MediaSources.FirstOrDefault();
+ if (mediaSource != null)
+ {
+ await SendSubtitles(localItem, mediaSource, provider, dataProvider, target, options, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ progress.Report(92);
+
+ transferSuccess = true;
+
+ progress.Report(99);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error transferring sync job file", ex);
+ transferException = ex;
+ }
+
+ if (transferSuccess)
+ {
+ await _syncManager.ReportSyncJobItemTransferred(jobItem.SyncJobItemId).ConfigureAwait(false);
+ }
+ else
+ {
+ await _syncManager.ReportSyncJobItemTransferFailed(jobItem.SyncJobItemId).ConfigureAwait(false);
+
+ throw transferException;
+ }
+ }
+
+ private async Task SendSubtitles(LocalItem localItem, MediaSourceInfo mediaSource, IServerSyncProvider provider, ISyncDataProvider dataProvider, SyncTarget target, SyncOptions options, CancellationToken cancellationToken)
+ {
+ var failedSubtitles = new List<MediaStream>();
+ var requiresSave = false;
+
+ foreach (var mediaStream in mediaSource.MediaStreams
+ .Where(i => i.Type == MediaStreamType.Subtitle && i.IsExternal)
+ .ToList())
+ {
+ try
+ {
+ var remotePath = GetRemoteSubtitlePath(localItem, mediaStream, provider, target);
+ var sendFileResult = await SendFile(provider, mediaStream.Path, remotePath, target, options, new Progress<double>(), cancellationToken).ConfigureAwait(false);
+
+ // This is the path that will be used when talking to the provider
+ mediaStream.ExternalId = sendFileResult.Id;
+
+ // Keep track of all additional files for cleanup later.
+ localItem.AdditionalFiles.Add(sendFileResult.Id);
+
+ // This is the public path clients will use
+ mediaStream.Path = sendFileResult.Path;
+ requiresSave = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending subtitle stream", ex);
+ failedSubtitles.Add(mediaStream);
+ }
+ }
+
+ if (failedSubtitles.Count > 0)
+ {
+ mediaSource.MediaStreams = mediaSource.MediaStreams.Except(failedSubtitles).ToList();
+ requiresSave = true;
+ }
+
+ if (requiresSave)
+ {
+ await dataProvider.AddOrUpdate(target, localItem).ConfigureAwait(false);
+ }
+ }
+
+ private string[] GetRemoteSubtitlePath(LocalItem item, MediaStream stream, IServerSyncProvider provider, SyncTarget target)
+ {
+ var filename = GetSubtitleSaveFileName(item, stream.Language, stream.IsForced) + "." + stream.Codec.ToLower();
+
+ var pathParts = item.LocalPath.Split(PathSeparatorChar);
+ var list = pathParts.Take(pathParts.Length - 1).ToList();
+ list.Add(filename);
+
+ return list.ToArray();
+ }
+
+ private string GetSubtitleSaveFileName(LocalItem item, string language, bool isForced)
+ {
+ var path = item.LocalPath;
+
+ var name = Path.GetFileNameWithoutExtension(path);
+
+ if (!string.IsNullOrWhiteSpace(language))
+ {
+ name += "." + language.ToLower();
+ }
+
+ if (isForced)
+ {
+ name += ".foreign";
+ }
+
+ return name;
+ }
+
+ private async Task RemoveItem(IServerSyncProvider provider,
+ ISyncDataProvider dataProvider,
+ string serverId,
+ string syncJobItemId,
+ SyncTarget target,
+ CancellationToken cancellationToken)
+ {
+ var localItems = await dataProvider.GetItemsBySyncJobItemId(target, serverId, syncJobItemId);
+
+ foreach (var localItem in localItems)
+ {
+ var files = localItem.AdditionalFiles.ToList();
+
+ foreach (var file in files)
+ {
+ _logger.Debug("Removing {0} from {1}.", file, target.Name);
+ await provider.DeleteFile(file, target, cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.Debug("Removing {0} from {1}.", localItem.FileId, target.Name);
+ await provider.DeleteFile(localItem.FileId, target, cancellationToken).ConfigureAwait(false);
+
+ await dataProvider.Delete(target, localItem.Id).ConfigureAwait(false);
+ }
+ }
+
+ private async Task<SyncedFileInfo> SendFile(IServerSyncProvider provider, string inputPath, string[] pathParts, SyncTarget target, SyncOptions options, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ _logger.Debug("Sending {0} to {1}. Remote path: {2}", inputPath, provider.Name, string.Join("/", pathParts));
+ var supportsDirectCopy = provider as ISupportsDirectCopy;
+ if (supportsDirectCopy != null)
+ {
+ return await supportsDirectCopy.SendFile(inputPath, pathParts, target, progress, cancellationToken).ConfigureAwait(false);
+ }
+
+ using (var fileStream = _fileSystem.GetFileStream(inputPath, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.Read, true))
+ {
+ Stream stream = fileStream;
+
+ if (options.UploadSpeedLimitBytes > 0 && provider is IRemoteSyncProvider)
+ {
+ stream = new ThrottledStream(stream, options.UploadSpeedLimitBytes);
+ }
+
+ return await provider.SendFile(stream, pathParts, target, progress, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private string GetLocalId(string jobItemId, string itemId)
+ {
+ var bytes = Encoding.UTF8.GetBytes(jobItemId + itemId);
+ bytes = CreateMd5(bytes);
+ return BitConverter.ToString(bytes, 0, bytes.Length).Replace("-", string.Empty);
+ }
+
+ private byte[] CreateMd5(byte[] value)
+ {
+ return _cryptographyProvider.ComputeMD5(value);
+ }
+
+ public LocalItem CreateLocalItem(IServerSyncProvider provider, SyncedItem syncedItem, SyncJob job, SyncTarget target, BaseItemDto libraryItem, string serverId, string serverName, string originalFileName)
+ {
+ var path = GetDirectoryPath(provider, job, syncedItem, libraryItem, serverName);
+ path.Add(GetLocalFileName(provider, libraryItem, originalFileName));
+
+ var localPath = string.Join(PathSeparatorString, path.ToArray());
+
+ foreach (var mediaSource in libraryItem.MediaSources)
+ {
+ mediaSource.Path = localPath;
+ mediaSource.Protocol = MediaProtocol.File;
+ }
+
+ return new LocalItem
+ {
+ Item = libraryItem,
+ ItemId = libraryItem.Id,
+ ServerId = serverId,
+ LocalPath = localPath,
+ Id = GetLocalId(syncedItem.SyncJobItemId, libraryItem.Id),
+ SyncJobItemId = syncedItem.SyncJobItemId
+ };
+ }
+
+ private List<string> GetDirectoryPath(IServerSyncProvider provider, SyncJob job, SyncedItem syncedItem, BaseItemDto item, string serverName)
+ {
+ var parts = new List<string>
+ {
+ serverName
+ };
+
+ var profileOption = _syncManager.GetProfileOptions(job.TargetId)
+ .FirstOrDefault(i => string.Equals(i.Id, job.Profile, StringComparison.OrdinalIgnoreCase));
+
+ string name;
+
+ if (profileOption != null && !string.IsNullOrWhiteSpace(profileOption.Name))
+ {
+ name = profileOption.Name;
+
+ if (job.Bitrate.HasValue)
+ {
+ name += "-" + job.Bitrate.Value.ToString(CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ var qualityOption = _syncManager.GetQualityOptions(job.TargetId)
+ .FirstOrDefault(i => string.Equals(i.Id, job.Quality, StringComparison.OrdinalIgnoreCase));
+
+ if (qualityOption != null && !string.IsNullOrWhiteSpace(qualityOption.Name))
+ {
+ name += "-" + qualityOption.Name;
+ }
+ }
+ }
+ else
+ {
+ name = syncedItem.SyncJobName + "-" + syncedItem.SyncJobDateCreated
+ .ToLocalTime()
+ .ToString("g")
+ .Replace(" ", "-");
+ }
+
+ name = GetValidFilename(provider, name);
+ parts.Add(name);
+
+ if (item.IsType("episode"))
+ {
+ parts.Add("TV");
+ if (!string.IsNullOrWhiteSpace(item.SeriesName))
+ {
+ parts.Add(item.SeriesName);
+ }
+ }
+ else if (item.IsVideo)
+ {
+ parts.Add("Videos");
+ parts.Add(item.Name);
+ }
+ else if (item.IsAudio)
+ {
+ parts.Add("Music");
+
+ if (!string.IsNullOrWhiteSpace(item.AlbumArtist))
+ {
+ parts.Add(item.AlbumArtist);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.Album))
+ {
+ parts.Add(item.Album);
+ }
+ }
+ else if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
+ {
+ parts.Add("Photos");
+
+ if (!string.IsNullOrWhiteSpace(item.Album))
+ {
+ parts.Add(item.Album);
+ }
+ }
+
+ return parts.Select(i => GetValidFilename(provider, i)).ToList();
+ }
+
+ private string GetLocalFileName(IServerSyncProvider provider, BaseItemDto item, string originalFileName)
+ {
+ var filename = originalFileName;
+
+ if (string.IsNullOrWhiteSpace(filename))
+ {
+ filename = item.Name;
+ }
+
+ return GetValidFilename(provider, filename);
+ }
+
+ private string GetValidFilename(IServerSyncProvider provider, string filename)
+ {
+ // We can always add this method to the sync provider if it's really needed
+ return _fileSystem.GetValidFilename(filename);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/MultiProviderSync.cs b/Emby.Server.Implementations/Sync/MultiProviderSync.cs
new file mode 100644
index 000000000..8189b8550
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/MultiProviderSync.cs
@@ -0,0 +1,79 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class MultiProviderSync
+ {
+ private readonly SyncManager _syncManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IConfigurationManager _config;
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ public MultiProviderSync(SyncManager syncManager, IServerApplicationHost appHost, ILogger logger, IFileSystem fileSystem, IConfigurationManager config, ICryptoProvider cryptographyProvider)
+ {
+ _syncManager = syncManager;
+ _appHost = appHost;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _config = config;
+ _cryptographyProvider = cryptographyProvider;
+ }
+
+ public async Task Sync(IEnumerable<IServerSyncProvider> providers, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var targets = providers
+ .SelectMany(i => i.GetAllSyncTargets().Select(t => new Tuple<IServerSyncProvider, SyncTarget>(i, t)))
+ .ToList();
+
+ var numComplete = 0;
+ double startingPercent = 0;
+ double percentPerItem = 1;
+ if (targets.Count > 0)
+ {
+ percentPerItem /= targets.Count;
+ }
+
+ foreach (var target in targets)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var currentPercent = startingPercent;
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(pct =>
+ {
+ var totalProgress = pct * percentPerItem;
+ totalProgress += currentPercent;
+ progress.Report(totalProgress);
+ });
+
+ var dataProvider = _syncManager.GetDataProvider(target.Item1, target.Item2);
+
+ await new MediaSync(_logger, _syncManager, _appHost, _fileSystem, _config, _cryptographyProvider)
+ .Sync(target.Item1, dataProvider, target.Item2, innerProgress, cancellationToken)
+ .ConfigureAwait(false);
+
+ numComplete++;
+ startingPercent = numComplete;
+ startingPercent /= targets.Count;
+ startingPercent *= 100;
+ progress.Report(startingPercent);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/ServerSyncScheduledTask.cs b/Emby.Server.Implementations/Sync/ServerSyncScheduledTask.cs
new file mode 100644
index 000000000..09a0bfde4
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/ServerSyncScheduledTask.cs
@@ -0,0 +1,95 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Sync
+{
+ class ServerSyncScheduledTask : IScheduledTask, IConfigurableScheduledTask
+ {
+ private readonly ISyncManager _syncManager;
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IConfigurationManager _config;
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ public ServerSyncScheduledTask(ISyncManager syncManager, ILogger logger, IFileSystem fileSystem, IServerApplicationHost appHost, IConfigurationManager config, ICryptoProvider cryptographyProvider)
+ {
+ _syncManager = syncManager;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _appHost = appHost;
+ _config = config;
+ _cryptographyProvider = cryptographyProvider;
+ }
+
+ public string Name
+ {
+ get { return "Cloud & Folder Sync"; }
+ }
+
+ public string Description
+ {
+ get { return "Sync media to the cloud"; }
+ }
+
+ public string Category
+ {
+ get
+ {
+ return "Sync";
+ }
+ }
+
+ public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ return new MultiProviderSync((SyncManager)_syncManager, _appHost, _logger, _fileSystem, _config, _cryptographyProvider)
+ .Sync(ServerSyncProviders, progress, cancellationToken);
+ }
+
+ public IEnumerable<IServerSyncProvider> ServerSyncProviders
+ {
+ get { return ((SyncManager)_syncManager).ServerSyncProviders; }
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(3).Ticks}
+ };
+ }
+ public bool IsHidden
+ {
+ get { return !IsEnabled; }
+ }
+
+ public bool IsEnabled
+ {
+ get { return ServerSyncProviders.Any(); }
+ }
+
+ public bool IsLogged
+ {
+ get { return true; }
+ }
+
+ public string Key
+ {
+ get { return "ServerSync"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncConfig.cs b/Emby.Server.Implementations/Sync/SyncConfig.cs
new file mode 100644
index 000000000..8a97326bd
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncConfig.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Sync;
+using System.Collections.Generic;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncConfigurationFactory : IConfigurationFactory
+ {
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new List<ConfigurationStore>
+ {
+ new ConfigurationStore
+ {
+ ConfigurationType = typeof(SyncOptions),
+ Key = "sync"
+ }
+ };
+ }
+ }
+
+ public static class SyncExtensions
+ {
+ public static SyncOptions GetSyncOptions(this IConfigurationManager config)
+ {
+ return config.GetConfiguration<SyncOptions>("sync");
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncConvertScheduledTask.cs b/Emby.Server.Implementations/Sync/SyncConvertScheduledTask.cs
new file mode 100644
index 000000000..8dafac7e1
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncConvertScheduledTask.cs
@@ -0,0 +1,89 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncConvertScheduledTask : IScheduledTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISyncRepository _syncRepo;
+ private readonly ISyncManager _syncManager;
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+ private readonly ITVSeriesManager _tvSeriesManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
+ public SyncConvertScheduledTask(ILibraryManager libraryManager, ISyncRepository syncRepo, ISyncManager syncManager, ILogger logger, IUserManager userManager, ITVSeriesManager tvSeriesManager, IMediaEncoder mediaEncoder, ISubtitleEncoder subtitleEncoder, IConfigurationManager config, IFileSystem fileSystem, IMediaSourceManager mediaSourceManager)
+ {
+ _libraryManager = libraryManager;
+ _syncRepo = syncRepo;
+ _syncManager = syncManager;
+ _logger = logger;
+ _userManager = userManager;
+ _tvSeriesManager = tvSeriesManager;
+ _mediaEncoder = mediaEncoder;
+ _subtitleEncoder = subtitleEncoder;
+ _config = config;
+ _fileSystem = fileSystem;
+ _mediaSourceManager = mediaSourceManager;
+ }
+
+ public string Name
+ {
+ get { return "Convert media"; }
+ }
+
+ public string Description
+ {
+ get { return "Runs scheduled sync jobs"; }
+ }
+
+ public string Category
+ {
+ get
+ {
+ return "Sync";
+ }
+ }
+
+ public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ return new SyncJobProcessor(_libraryManager, _syncRepo, (SyncManager)_syncManager, _logger, _userManager, _tvSeriesManager, _mediaEncoder, _subtitleEncoder, _config, _fileSystem, _mediaSourceManager)
+ .Sync(progress, cancellationToken);
+ }
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[] {
+
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(3).Ticks}
+ };
+ }
+
+ public string Key
+ {
+ get { return "SyncPrepare"; }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncHelper.cs b/Emby.Server.Implementations/Sync/SyncHelper.cs
new file mode 100644
index 000000000..da475f003
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncHelper.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncHelper
+ {
+ public static int? AdjustBitrate(int? profileBitrate, string quality)
+ {
+ if (profileBitrate.HasValue)
+ {
+ if (string.Equals(quality, "medium", StringComparison.OrdinalIgnoreCase))
+ {
+ profileBitrate = Math.Min(profileBitrate.Value, 4000000);
+ }
+ else if (string.Equals(quality, "low", StringComparison.OrdinalIgnoreCase))
+ {
+ profileBitrate = Math.Min(profileBitrate.Value, 1500000);
+ }
+ }
+
+ return profileBitrate;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncJobOptions.cs b/Emby.Server.Implementations/Sync/SyncJobOptions.cs
new file mode 100644
index 000000000..8e4d8e2ed
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncJobOptions.cs
@@ -0,0 +1,18 @@
+using MediaBrowser.Model.Dlna;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncJobOptions
+ {
+ /// <summary>
+ /// Gets or sets the conversion options.
+ /// </summary>
+ /// <value>The conversion options.</value>
+ public DeviceProfile DeviceProfile { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether this instance is converting.
+ /// </summary>
+ /// <value><c>true</c> if this instance is converting; otherwise, <c>false</c>.</value>
+ public bool IsConverting { get; set; }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncJobProcessor.cs b/Emby.Server.Implementations/Sync/SyncJobProcessor.cs
new file mode 100644
index 000000000..415757609
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncJobProcessor.cs
@@ -0,0 +1,988 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncJobProcessor
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISyncRepository _syncRepo;
+ private readonly SyncManager _syncManager;
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+ private readonly ITVSeriesManager _tvSeriesManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
+ public SyncJobProcessor(ILibraryManager libraryManager, ISyncRepository syncRepo, SyncManager syncManager, ILogger logger, IUserManager userManager, ITVSeriesManager tvSeriesManager, IMediaEncoder mediaEncoder, ISubtitleEncoder subtitleEncoder, IConfigurationManager config, IFileSystem fileSystem, IMediaSourceManager mediaSourceManager)
+ {
+ _libraryManager = libraryManager;
+ _syncRepo = syncRepo;
+ _syncManager = syncManager;
+ _logger = logger;
+ _userManager = userManager;
+ _tvSeriesManager = tvSeriesManager;
+ _mediaEncoder = mediaEncoder;
+ _subtitleEncoder = subtitleEncoder;
+ _config = config;
+ _fileSystem = fileSystem;
+ _mediaSourceManager = mediaSourceManager;
+ }
+
+ public async Task EnsureJobItems(SyncJob job)
+ {
+ var user = _userManager.GetUserById(job.UserId);
+
+ if (user == null)
+ {
+ throw new InvalidOperationException("Cannot proceed with sync because user no longer exists.");
+ }
+
+ var items = (await GetItemsForSync(job.Category, job.ParentId, job.RequestedItemIds, user, job.UnwatchedOnly).ConfigureAwait(false))
+ .ToList();
+
+ var jobItems = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ JobId = job.Id,
+ AddMetadata = false
+
+ }).Items.ToList();
+
+ foreach (var item in items)
+ {
+ // Respect ItemLimit, if set
+ if (job.ItemLimit.HasValue)
+ {
+ if (jobItems.Count(j => j.Status != SyncJobItemStatus.RemovedFromDevice && j.Status != SyncJobItemStatus.Failed) >= job.ItemLimit.Value)
+ {
+ break;
+ }
+ }
+
+ var itemId = item.Id.ToString("N");
+
+ var jobItem = jobItems.FirstOrDefault(i => string.Equals(i.ItemId, itemId, StringComparison.OrdinalIgnoreCase));
+
+ if (jobItem != null)
+ {
+ continue;
+ }
+
+ var index = jobItems.Count == 0 ?
+ 0 :
+ jobItems.Select(i => i.JobItemIndex).Max() + 1;
+
+ jobItem = new SyncJobItem
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ ItemId = itemId,
+ ItemName = GetSyncJobItemName(item),
+ JobId = job.Id,
+ TargetId = job.TargetId,
+ DateCreated = DateTime.UtcNow,
+ JobItemIndex = index
+ };
+
+ await _syncRepo.Create(jobItem).ConfigureAwait(false);
+ _syncManager.OnSyncJobItemCreated(jobItem);
+
+ jobItems.Add(jobItem);
+ }
+
+ jobItems = jobItems
+ .OrderBy(i => i.DateCreated)
+ .ToList();
+
+ await UpdateJobStatus(job, jobItems).ConfigureAwait(false);
+ }
+
+ private string GetSyncJobItemName(BaseItem item)
+ {
+ var name = item.Name;
+ var episode = item as Episode;
+
+ if (episode != null)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ name = "E" + episode.IndexNumber.Value.ToString(CultureInfo.InvariantCulture) + " - " + name;
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ name = "S" + episode.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture) + ", " + name;
+ }
+ }
+
+ return name;
+ }
+
+ public Task UpdateJobStatus(string id)
+ {
+ var job = _syncRepo.GetJob(id);
+
+ if (job == null)
+ {
+ return Task.FromResult(true);
+ }
+
+ var result = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ JobId = job.Id,
+ AddMetadata = false
+ });
+
+ return UpdateJobStatus(job, result.Items.ToList());
+ }
+
+ private async Task UpdateJobStatus(SyncJob job, List<SyncJobItem> jobItems)
+ {
+ job.ItemCount = jobItems.Count;
+
+ double pct = 0;
+
+ foreach (var item in jobItems)
+ {
+ if (item.Status == SyncJobItemStatus.Failed || item.Status == SyncJobItemStatus.Synced || item.Status == SyncJobItemStatus.RemovedFromDevice || item.Status == SyncJobItemStatus.Cancelled)
+ {
+ pct += 100;
+ }
+ else
+ {
+ pct += item.Progress ?? 0;
+ }
+ }
+
+ if (job.ItemCount > 0)
+ {
+ pct /= job.ItemCount;
+ job.Progress = pct;
+ }
+ else
+ {
+ job.Progress = null;
+ }
+
+ if (jobItems.Any(i => i.Status == SyncJobItemStatus.Transferring))
+ {
+ job.Status = SyncJobStatus.Transferring;
+ }
+ else if (jobItems.Any(i => i.Status == SyncJobItemStatus.Converting))
+ {
+ job.Status = SyncJobStatus.Converting;
+ }
+ else if (jobItems.All(i => i.Status == SyncJobItemStatus.Failed))
+ {
+ job.Status = SyncJobStatus.Failed;
+ }
+ else if (jobItems.All(i => i.Status == SyncJobItemStatus.Cancelled))
+ {
+ job.Status = SyncJobStatus.Cancelled;
+ }
+ else if (jobItems.All(i => i.Status == SyncJobItemStatus.ReadyToTransfer))
+ {
+ job.Status = SyncJobStatus.ReadyToTransfer;
+ }
+ else if (jobItems.All(i => i.Status == SyncJobItemStatus.Cancelled || i.Status == SyncJobItemStatus.Failed || i.Status == SyncJobItemStatus.Synced || i.Status == SyncJobItemStatus.RemovedFromDevice))
+ {
+ if (jobItems.Any(i => i.Status == SyncJobItemStatus.Failed))
+ {
+ job.Status = SyncJobStatus.CompletedWithError;
+ }
+ else
+ {
+ job.Status = SyncJobStatus.Completed;
+ }
+ }
+ else
+ {
+ job.Status = SyncJobStatus.Queued;
+ }
+
+ await _syncRepo.Update(job).ConfigureAwait(false);
+
+ _syncManager.OnSyncJobUpdated(job);
+ }
+
+ public async Task<IEnumerable<BaseItem>> GetItemsForSync(SyncCategory? category, string parentId, IEnumerable<string> itemIds, User user, bool unwatchedOnly)
+ {
+ var list = new List<BaseItem>();
+
+ if (category.HasValue)
+ {
+ list = (await GetItemsForSync(category.Value, parentId, user).ConfigureAwait(false)).ToList();
+ }
+ else
+ {
+ foreach (var itemId in itemIds)
+ {
+ var subList = await GetItemsForSync(itemId, user).ConfigureAwait(false);
+ list.AddRange(subList);
+ }
+ }
+
+ IEnumerable<BaseItem> items = list;
+ items = items.Where(_syncManager.SupportsSync);
+
+ if (unwatchedOnly)
+ {
+ // Avoid implicitly captured closure
+ var currentUser = user;
+
+ items = items.Where(i =>
+ {
+ var video = i as Video;
+
+ if (video != null)
+ {
+ return !video.IsPlayed(currentUser);
+ }
+
+ return true;
+ });
+ }
+
+ return items.DistinctBy(i => i.Id);
+ }
+
+ private async Task<IEnumerable<BaseItem>> GetItemsForSync(SyncCategory category, string parentId, User user)
+ {
+ var parent = string.IsNullOrWhiteSpace(parentId)
+ ? user.RootFolder
+ : (Folder)_libraryManager.GetItemById(parentId);
+
+ InternalItemsQuery query;
+
+ switch (category)
+ {
+ case SyncCategory.Latest:
+ query = new InternalItemsQuery
+ {
+ IsFolder = false,
+ SortBy = new[] { ItemSortBy.DateCreated, ItemSortBy.SortName },
+ SortOrder = SortOrder.Descending,
+ Recursive = true
+ };
+ break;
+ case SyncCategory.Resume:
+ query = new InternalItemsQuery
+ {
+ IsFolder = false,
+ SortBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.SortName },
+ SortOrder = SortOrder.Descending,
+ Recursive = true,
+ IsResumable = true,
+ MediaTypes = new[] { MediaType.Video }
+ };
+ break;
+
+ case SyncCategory.NextUp:
+ return _tvSeriesManager.GetNextUp(new NextUpQuery
+ {
+ ParentId = parentId,
+ UserId = user.Id.ToString("N")
+ }).Items;
+
+ default:
+ throw new ArgumentException("Unrecognized category: " + category);
+ }
+
+ if (parent == null)
+ {
+ return new List<BaseItem>();
+ }
+
+ query.User = user;
+
+ var result = await parent.GetItems(query).ConfigureAwait(false);
+ return result.Items;
+ }
+
+ private async Task<List<BaseItem>> GetItemsForSync(string id, User user)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ return new List<BaseItem>();
+ }
+
+ var itemByName = item as IItemByName;
+ if (itemByName != null)
+ {
+ return itemByName.GetTaggedItems(new InternalItemsQuery(user)
+ {
+ IsFolder = false,
+ Recursive = true
+ }).ToList();
+ }
+
+ if (item.IsFolder)
+ {
+ var folder = (Folder)item;
+ var itemsResult = await folder.GetItems(new InternalItemsQuery(user)
+ {
+ Recursive = true,
+ IsFolder = false
+
+ }).ConfigureAwait(false);
+
+ var items = itemsResult.Items;
+
+ if (!folder.IsPreSorted)
+ {
+ items = _libraryManager.Sort(items, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending)
+ .ToArray();
+ }
+
+ return items.ToList();
+ }
+
+ return new List<BaseItem> { item };
+ }
+
+ private async Task EnsureSyncJobItems(string targetId, CancellationToken cancellationToken)
+ {
+ var jobResult = _syncRepo.GetJobs(new SyncJobQuery
+ {
+ SyncNewContent = true,
+ TargetId = targetId
+ });
+
+ foreach (var job in jobResult.Items)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (job.SyncNewContent)
+ {
+ await EnsureJobItems(job).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public async Task Sync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ await EnsureSyncJobItems(null, cancellationToken).ConfigureAwait(false);
+
+ // Look job items that are supposedly transfering, but need to be requeued because the synced files have been deleted somehow
+ await HandleDeletedSyncFiles(cancellationToken).ConfigureAwait(false);
+
+ // If it already has a converting status then is must have been aborted during conversion
+ var result = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ Statuses = new[] { SyncJobItemStatus.Queued, SyncJobItemStatus.Converting },
+ AddMetadata = false
+ });
+
+ await SyncJobItems(result.Items, true, progress, cancellationToken).ConfigureAwait(false);
+
+ CleanDeadSyncFiles();
+ }
+
+ private async Task HandleDeletedSyncFiles(CancellationToken cancellationToken)
+ {
+ // Look job items that are supposedly transfering, but need to be requeued because the synced files have been deleted somehow
+ var result = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ Statuses = new[] { SyncJobItemStatus.ReadyToTransfer, SyncJobItemStatus.Transferring },
+ AddMetadata = false
+ });
+
+ foreach (var item in result.Items)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrWhiteSpace(item.OutputPath) || !_fileSystem.FileExists(item.OutputPath))
+ {
+ item.Status = SyncJobItemStatus.Queued;
+ await _syncManager.UpdateSyncJobItemInternal(item).ConfigureAwait(false);
+ await UpdateJobStatus(item.JobId).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private void CleanDeadSyncFiles()
+ {
+ // TODO
+ // Clean files in sync temp folder that are not linked to any sync jobs
+ }
+
+ public async Task SyncJobItems(string targetId, bool enableConversion, IProgress<double> progress,
+ CancellationToken cancellationToken)
+ {
+ await EnsureSyncJobItems(targetId, cancellationToken).ConfigureAwait(false);
+
+ // If it already has a converting status then is must have been aborted during conversion
+ var result = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ Statuses = new[] { SyncJobItemStatus.Queued, SyncJobItemStatus.Converting },
+ TargetId = targetId,
+ AddMetadata = false
+ });
+
+ await SyncJobItems(result.Items, enableConversion, progress, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task SyncJobItems(SyncJobItem[] items, bool enableConversion, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ if (items.Length > 0)
+ {
+ if (!SyncRegistrationInfo.Instance.IsRegistered)
+ {
+ _logger.Debug("Cancelling sync job processing. Please obtain a supporter membership.");
+ return;
+ }
+ }
+
+ var numComplete = 0;
+
+ foreach (var item in items)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ double percentPerItem = 1;
+ percentPerItem /= items.Length;
+ var startingPercent = numComplete * percentPerItem * 100;
+
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(p => progress.Report(startingPercent + percentPerItem * p));
+
+ // Pull it fresh from the db just to make sure it wasn't deleted or cancelled while another item was converting
+ var jobItem = enableConversion ? _syncRepo.GetJobItem(item.Id) : item;
+
+ if (jobItem != null)
+ {
+ if (jobItem.Status != SyncJobItemStatus.Cancelled)
+ {
+ await ProcessJobItem(jobItem, enableConversion, innerProgress, cancellationToken).ConfigureAwait(false);
+ }
+
+ await UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= items.Length;
+ progress.Report(100 * percent);
+ }
+ }
+
+ private async Task ProcessJobItem(SyncJobItem jobItem, bool enableConversion, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ if (jobItem == null)
+ {
+ throw new ArgumentNullException("jobItem");
+ }
+
+ var item = _libraryManager.GetItemById(jobItem.ItemId);
+ if (item == null)
+ {
+ jobItem.Status = SyncJobItemStatus.Failed;
+ _logger.Error("Unable to locate library item for JobItem {0}, ItemId {1}", jobItem.Id, jobItem.ItemId);
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ return;
+ }
+
+ jobItem.Progress = 0;
+
+ var syncOptions = _config.GetSyncOptions();
+ var job = _syncManager.GetJob(jobItem.JobId);
+ var user = _userManager.GetUserById(job.UserId);
+ if (user == null)
+ {
+ jobItem.Status = SyncJobItemStatus.Failed;
+ _logger.Error("User not found. Cannot complete the sync job.");
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ return;
+ }
+
+ // See if there's already another active job item for the same target
+ var existingJobItems = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ AddMetadata = false,
+ ItemId = jobItem.ItemId,
+ TargetId = jobItem.TargetId,
+ Statuses = new[] { SyncJobItemStatus.Converting, SyncJobItemStatus.Queued, SyncJobItemStatus.ReadyToTransfer, SyncJobItemStatus.Synced, SyncJobItemStatus.Transferring }
+ });
+
+ var duplicateJobItems = existingJobItems.Items
+ .Where(i => !string.Equals(i.Id, jobItem.Id, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (duplicateJobItems.Count > 0)
+ {
+ var syncProvider = _syncManager.GetSyncProvider(jobItem) as IHasDuplicateCheck;
+
+ if (!duplicateJobItems.Any(i => AllowDuplicateJobItem(syncProvider, i, jobItem)))
+ {
+ _logger.Debug("Cancelling sync job item because there is already another active job for the same target.");
+ jobItem.Status = SyncJobItemStatus.Cancelled;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ return;
+ }
+ }
+
+ var video = item as Video;
+ if (video != null)
+ {
+ await Sync(jobItem, video, user, enableConversion, syncOptions, progress, cancellationToken).ConfigureAwait(false);
+ }
+
+ else if (item is Audio)
+ {
+ await Sync(jobItem, (Audio)item, user, enableConversion, syncOptions, progress, cancellationToken).ConfigureAwait(false);
+ }
+
+ else if (item is Photo)
+ {
+ await Sync(jobItem, (Photo)item, cancellationToken).ConfigureAwait(false);
+ }
+
+ else
+ {
+ await SyncGeneric(jobItem, item, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private bool AllowDuplicateJobItem(IHasDuplicateCheck provider, SyncJobItem original, SyncJobItem duplicate)
+ {
+ if (provider != null)
+ {
+ return provider.AllowDuplicateJobItem(original, duplicate);
+ }
+
+ return true;
+ }
+
+ private async Task Sync(SyncJobItem jobItem, Video item, User user, bool enableConversion, SyncOptions syncOptions, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var job = _syncManager.GetJob(jobItem.JobId);
+ var jobOptions = _syncManager.GetVideoOptions(jobItem, job);
+ var conversionOptions = new VideoOptions
+ {
+ Profile = jobOptions.DeviceProfile
+ };
+
+ conversionOptions.DeviceId = jobItem.TargetId;
+ conversionOptions.Context = EncodingContext.Static;
+ conversionOptions.ItemId = item.Id.ToString("N");
+ conversionOptions.MediaSources = _mediaSourceManager.GetStaticMediaSources(item, false, user).ToList();
+
+ var streamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(conversionOptions);
+ var mediaSource = streamInfo.MediaSource;
+
+ // No sense creating external subs if we're already burning one into the video
+ var externalSubs = streamInfo.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode ?
+ new List<SubtitleStreamInfo>() :
+ streamInfo.GetExternalSubtitles(false, true, null, null);
+
+ // Mark as requiring conversion if transcoding the video, or if any subtitles need to be extracted
+ var requiresVideoTranscoding = streamInfo.PlayMethod == PlayMethod.Transcode && jobOptions.IsConverting;
+ var requiresConversion = requiresVideoTranscoding || externalSubs.Any(i => RequiresExtraction(i, mediaSource));
+
+ if (requiresConversion && !enableConversion)
+ {
+ return;
+ }
+
+ jobItem.MediaSourceId = streamInfo.MediaSourceId;
+ jobItem.TemporaryPath = GetTemporaryPath(jobItem);
+
+ if (requiresConversion)
+ {
+ jobItem.Status = SyncJobItemStatus.Converting;
+ }
+
+ if (requiresVideoTranscoding)
+ {
+ // Save the job item now since conversion could take a while
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ await UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+
+ try
+ {
+ var lastJobUpdate = DateTime.MinValue;
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(async pct =>
+ {
+ progress.Report(pct);
+
+ if ((DateTime.UtcNow - lastJobUpdate).TotalSeconds >= DatabaseProgressUpdateIntervalSeconds)
+ {
+ jobItem.Progress = pct / 2;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ await UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+ });
+
+ jobItem.OutputPath = await _mediaEncoder.EncodeVideo(new EncodingJobOptions(streamInfo, conversionOptions.Profile)
+ {
+ OutputDirectory = jobItem.TemporaryPath,
+ CpuCoreLimit = syncOptions.TranscodingCpuCoreLimit,
+ ReadInputAtNativeFramerate = !syncOptions.EnableFullSpeedTranscoding
+
+ }, innerProgress, cancellationToken);
+
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ _syncManager.OnConversionComplete(jobItem);
+ }
+ catch (OperationCanceledException)
+ {
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ }
+ catch (Exception ex)
+ {
+ jobItem.Status = SyncJobItemStatus.Failed;
+ _logger.ErrorException("Error during sync transcoding", ex);
+ }
+
+ if (jobItem.Status == SyncJobItemStatus.Failed || jobItem.Status == SyncJobItemStatus.Queued)
+ {
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ return;
+ }
+
+ jobItem.MediaSource = await GetEncodedMediaSource(jobItem.OutputPath, user, true).ConfigureAwait(false);
+ }
+ else
+ {
+ if (mediaSource.Protocol == MediaProtocol.File)
+ {
+ jobItem.OutputPath = mediaSource.Path;
+ }
+ else if (mediaSource.Protocol == MediaProtocol.Http)
+ {
+ jobItem.OutputPath = await DownloadFile(jobItem, mediaSource, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ throw new InvalidOperationException(string.Format("Cannot direct stream {0} protocol", mediaSource.Protocol));
+ }
+
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ jobItem.MediaSource = mediaSource;
+ }
+
+ jobItem.MediaSource.SupportsTranscoding = false;
+
+ if (externalSubs.Count > 0)
+ {
+ // Save the job item now since conversion could take a while
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ await ConvertSubtitles(jobItem, externalSubs, streamInfo, cancellationToken).ConfigureAwait(false);
+ }
+
+ jobItem.Progress = 50;
+ jobItem.Status = SyncJobItemStatus.ReadyToTransfer;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+
+ private bool RequiresExtraction(SubtitleStreamInfo stream, MediaSourceInfo mediaSource)
+ {
+ var originalStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == stream.Index);
+
+ return originalStream != null && !originalStream.IsExternal;
+ }
+
+ private async Task ConvertSubtitles(SyncJobItem jobItem,
+ IEnumerable<SubtitleStreamInfo> subtitles,
+ StreamInfo streamInfo,
+ CancellationToken cancellationToken)
+ {
+ var files = new List<ItemFileInfo>();
+
+ var mediaStreams = jobItem.MediaSource.MediaStreams
+ .Where(i => i.Type != MediaStreamType.Subtitle || !i.IsExternal)
+ .ToList();
+
+ var startingIndex = mediaStreams.Count == 0 ?
+ 0 :
+ mediaStreams.Select(i => i.Index).Max() + 1;
+
+ foreach (var subtitle in subtitles)
+ {
+ var fileInfo = await ConvertSubtitles(jobItem.TemporaryPath, streamInfo, subtitle, cancellationToken).ConfigureAwait(false);
+
+ // Reset this to a value that will be based on the output media
+ fileInfo.Index = startingIndex;
+ files.Add(fileInfo);
+
+ mediaStreams.Add(new MediaStream
+ {
+ Index = startingIndex,
+ Codec = subtitle.Format,
+ IsForced = subtitle.IsForced,
+ IsExternal = true,
+ Language = subtitle.Language,
+ Path = fileInfo.Path,
+ SupportsExternalStream = true,
+ Type = MediaStreamType.Subtitle
+ });
+
+ startingIndex++;
+ }
+
+ jobItem.AdditionalFiles.AddRange(files);
+
+ jobItem.MediaSource.MediaStreams = mediaStreams;
+ }
+
+ private async Task<ItemFileInfo> ConvertSubtitles(string temporaryPath, StreamInfo streamInfo, SubtitleStreamInfo subtitleStreamInfo, CancellationToken cancellationToken)
+ {
+ var subtitleStreamIndex = subtitleStreamInfo.Index;
+
+ var filename = Guid.NewGuid() + "." + subtitleStreamInfo.Format.ToLower();
+
+ var path = Path.Combine(temporaryPath, filename);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(path));
+
+ using (var stream = await _subtitleEncoder.GetSubtitles(streamInfo.ItemId, streamInfo.MediaSourceId, subtitleStreamIndex, subtitleStreamInfo.Format, 0, null, false, cancellationToken).ConfigureAwait(false))
+ {
+ using (var fs = _fileSystem.GetFileStream(path, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true))
+ {
+ await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return new ItemFileInfo
+ {
+ Name = Path.GetFileName(path),
+ Path = path,
+ Type = ItemFileType.Subtitles,
+ Index = subtitleStreamIndex
+ };
+ }
+
+ private const int DatabaseProgressUpdateIntervalSeconds = 2;
+
+ private async Task Sync(SyncJobItem jobItem, Audio item, User user, bool enableConversion, SyncOptions syncOptions, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var job = _syncManager.GetJob(jobItem.JobId);
+ var jobOptions = _syncManager.GetAudioOptions(jobItem, job);
+ var conversionOptions = new AudioOptions
+ {
+ Profile = jobOptions.DeviceProfile
+ };
+
+ conversionOptions.DeviceId = jobItem.TargetId;
+ conversionOptions.Context = EncodingContext.Static;
+ conversionOptions.ItemId = item.Id.ToString("N");
+ conversionOptions.MediaSources = _mediaSourceManager.GetStaticMediaSources(item, false, user).ToList();
+
+ var streamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(conversionOptions);
+ var mediaSource = streamInfo.MediaSource;
+
+ jobItem.MediaSourceId = streamInfo.MediaSourceId;
+ jobItem.TemporaryPath = GetTemporaryPath(jobItem);
+
+ if (streamInfo.PlayMethod == PlayMethod.Transcode && jobOptions.IsConverting)
+ {
+ if (!enableConversion)
+ {
+ return;
+ }
+
+ jobItem.Status = SyncJobItemStatus.Converting;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ await UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+
+ try
+ {
+ var lastJobUpdate = DateTime.MinValue;
+ var innerProgress = new ActionableProgress<double>();
+ innerProgress.RegisterAction(async pct =>
+ {
+ progress.Report(pct);
+
+ if ((DateTime.UtcNow - lastJobUpdate).TotalSeconds >= DatabaseProgressUpdateIntervalSeconds)
+ {
+ jobItem.Progress = pct / 2;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ await UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+ });
+
+ jobItem.OutputPath = await _mediaEncoder.EncodeAudio(new EncodingJobOptions(streamInfo, conversionOptions.Profile)
+ {
+ OutputDirectory = jobItem.TemporaryPath,
+ CpuCoreLimit = syncOptions.TranscodingCpuCoreLimit
+
+ }, innerProgress, cancellationToken);
+
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ _syncManager.OnConversionComplete(jobItem);
+ }
+ catch (OperationCanceledException)
+ {
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ }
+ catch (Exception ex)
+ {
+ jobItem.Status = SyncJobItemStatus.Failed;
+ _logger.ErrorException("Error during sync transcoding", ex);
+ }
+
+ if (jobItem.Status == SyncJobItemStatus.Failed || jobItem.Status == SyncJobItemStatus.Queued)
+ {
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ return;
+ }
+
+ jobItem.MediaSource = await GetEncodedMediaSource(jobItem.OutputPath, user, false).ConfigureAwait(false);
+ }
+ else
+ {
+ if (mediaSource.Protocol == MediaProtocol.File)
+ {
+ jobItem.OutputPath = mediaSource.Path;
+ }
+ else if (mediaSource.Protocol == MediaProtocol.Http)
+ {
+ jobItem.OutputPath = await DownloadFile(jobItem, mediaSource, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ throw new InvalidOperationException(string.Format("Cannot direct stream {0} protocol", mediaSource.Protocol));
+ }
+
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ jobItem.MediaSource = mediaSource;
+ }
+
+ jobItem.MediaSource.SupportsTranscoding = false;
+
+ jobItem.Progress = 50;
+ jobItem.Status = SyncJobItemStatus.ReadyToTransfer;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+
+ private async Task Sync(SyncJobItem jobItem, Photo item, CancellationToken cancellationToken)
+ {
+ jobItem.OutputPath = item.Path;
+
+ jobItem.Progress = 50;
+ jobItem.Status = SyncJobItemStatus.ReadyToTransfer;
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+
+ private async Task SyncGeneric(SyncJobItem jobItem, BaseItem item, CancellationToken cancellationToken)
+ {
+ jobItem.OutputPath = item.Path;
+
+ jobItem.Progress = 50;
+ jobItem.Status = SyncJobItemStatus.ReadyToTransfer;
+ jobItem.ItemDateModifiedTicks = item.DateModified.Ticks;
+ await _syncManager.UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+
+ private async Task<string> DownloadFile(SyncJobItem jobItem, MediaSourceInfo mediaSource, CancellationToken cancellationToken)
+ {
+ // TODO: Download
+ return mediaSource.Path;
+ }
+
+ public string GetTemporaryPath(SyncJob job)
+ {
+ return GetTemporaryPath(job.Id);
+ }
+
+ public string GetTemporaryPath(string jobId)
+ {
+ var basePath = _config.GetSyncOptions().TemporaryPath;
+
+ if (string.IsNullOrWhiteSpace(basePath))
+ {
+ basePath = Path.Combine(_config.CommonApplicationPaths.ProgramDataPath, "sync");
+ }
+
+ return Path.Combine(basePath, jobId);
+ }
+
+ public string GetTemporaryPath(SyncJobItem jobItem)
+ {
+ return Path.Combine(GetTemporaryPath(jobItem.JobId), jobItem.Id);
+ }
+
+ private async Task<MediaSourceInfo> GetEncodedMediaSource(string path, User user, bool isVideo)
+ {
+ var item = _libraryManager.ResolvePath(_fileSystem.GetFileSystemInfo(path));
+
+ await item.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
+
+ var hasMediaSources = item as IHasMediaSources;
+
+ var mediaSources = _mediaSourceManager.GetStaticMediaSources(hasMediaSources, false).ToList();
+
+ var preferredAudio = string.IsNullOrEmpty(user.Configuration.AudioLanguagePreference)
+ ? new string[] { }
+ : new[] { user.Configuration.AudioLanguagePreference };
+
+ var preferredSubs = string.IsNullOrEmpty(user.Configuration.SubtitleLanguagePreference)
+ ? new List<string>() : new List<string> { user.Configuration.SubtitleLanguagePreference };
+
+ foreach (var source in mediaSources)
+ {
+ if (isVideo)
+ {
+ source.DefaultAudioStreamIndex =
+ MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.Configuration.PlayDefaultAudioTrack);
+
+ var defaultAudioIndex = source.DefaultAudioStreamIndex;
+ var audioLangage = defaultAudioIndex == null
+ ? null
+ : source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault();
+
+ source.DefaultAudioStreamIndex =
+ MediaStreamSelector.GetDefaultSubtitleStreamIndex(source.MediaStreams, preferredSubs, user.Configuration.SubtitleMode, audioLangage);
+ }
+ else
+ {
+ var audio = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
+
+ if (audio != null)
+ {
+ source.DefaultAudioStreamIndex = audio.Index;
+ }
+
+ }
+ }
+
+ return mediaSources.FirstOrDefault();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncManager.cs b/Emby.Server.Implementations/Sync/SyncManager.cs
new file mode 100644
index 000000000..13f60f5ee
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncManager.cs
@@ -0,0 +1,1362 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Sync;
+using MediaBrowser.Model.Users;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncManager : ISyncManager
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISyncRepository _repo;
+ private readonly IImageProcessor _imageProcessor;
+ private readonly ILogger _logger;
+ private readonly IUserManager _userManager;
+ private readonly Func<IDtoService> _dtoService;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ITVSeriesManager _tvSeriesManager;
+ private readonly Func<IMediaEncoder> _mediaEncoder;
+ private readonly IFileSystem _fileSystem;
+ private readonly Func<ISubtitleEncoder> _subtitleEncoder;
+ private readonly IConfigurationManager _config;
+ private readonly IUserDataManager _userDataManager;
+ private readonly Func<IMediaSourceManager> _mediaSourceManager;
+ private readonly IJsonSerializer _json;
+ private readonly ITaskManager _taskManager;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ private ISyncProvider[] _providers = { };
+
+ public event EventHandler<GenericEventArgs<SyncJobCreationResult>> SyncJobCreated;
+ public event EventHandler<GenericEventArgs<SyncJob>> SyncJobCancelled;
+ public event EventHandler<GenericEventArgs<SyncJob>> SyncJobUpdated;
+ public event EventHandler<GenericEventArgs<SyncJobItem>> SyncJobItemUpdated;
+ public event EventHandler<GenericEventArgs<SyncJobItem>> SyncJobItemCreated;
+
+ public SyncManager(ILibraryManager libraryManager, ISyncRepository repo, IImageProcessor imageProcessor, ILogger logger, IUserManager userManager, Func<IDtoService> dtoService, IServerApplicationHost appHost, ITVSeriesManager tvSeriesManager, Func<IMediaEncoder> mediaEncoder, IFileSystem fileSystem, Func<ISubtitleEncoder> subtitleEncoder, IConfigurationManager config, IUserDataManager userDataManager, Func<IMediaSourceManager> mediaSourceManager, IJsonSerializer json, ITaskManager taskManager, IMemoryStreamFactory memoryStreamProvider)
+ {
+ _libraryManager = libraryManager;
+ _repo = repo;
+ _imageProcessor = imageProcessor;
+ _logger = logger;
+ _userManager = userManager;
+ _dtoService = dtoService;
+ _appHost = appHost;
+ _tvSeriesManager = tvSeriesManager;
+ _mediaEncoder = mediaEncoder;
+ _fileSystem = fileSystem;
+ _subtitleEncoder = subtitleEncoder;
+ _config = config;
+ _userDataManager = userDataManager;
+ _mediaSourceManager = mediaSourceManager;
+ _json = json;
+ _taskManager = taskManager;
+ _memoryStreamProvider = memoryStreamProvider;
+ }
+
+ public void AddParts(IEnumerable<ISyncProvider> providers)
+ {
+ _providers = providers.ToArray();
+ }
+
+ public IEnumerable<IServerSyncProvider> ServerSyncProviders
+ {
+ get { return _providers.OfType<IServerSyncProvider>(); }
+ }
+
+ private readonly ConcurrentDictionary<string, ISyncDataProvider> _dataProviders =
+ new ConcurrentDictionary<string, ISyncDataProvider>(StringComparer.OrdinalIgnoreCase);
+
+ public ISyncDataProvider GetDataProvider(IServerSyncProvider provider, SyncTarget target)
+ {
+ return _dataProviders.GetOrAdd(target.Id, key => new TargetDataProvider(provider, target, _appHost, _logger, _json, _fileSystem, _config.CommonApplicationPaths, _memoryStreamProvider));
+ }
+
+ public async Task<SyncJobCreationResult> CreateJob(SyncJobRequest request)
+ {
+ var processor = GetSyncJobProcessor();
+
+ var user = _userManager.GetUserById(request.UserId);
+
+ var items = (await processor
+ .GetItemsForSync(request.Category, request.ParentId, request.ItemIds, user, request.UnwatchedOnly).ConfigureAwait(false))
+ .ToList();
+
+ if (items.Any(i => !SupportsSync(i)))
+ {
+ throw new ArgumentException("Item does not support sync.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ if (request.ItemIds.Count == 1)
+ {
+ request.Name = GetDefaultName(_libraryManager.GetItemById(request.ItemIds[0]));
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ request.Name = DateTime.Now.ToString("f1", CultureInfo.CurrentCulture);
+ }
+
+ var target = GetSyncTargets(request.UserId)
+ .FirstOrDefault(i => string.Equals(request.TargetId, i.Id));
+
+ if (target == null)
+ {
+ throw new ArgumentException("Sync target not found.");
+ }
+
+ var jobId = Guid.NewGuid().ToString("N");
+
+ if (string.IsNullOrWhiteSpace(request.Quality))
+ {
+ request.Quality = GetQualityOptions(request.TargetId)
+ .Where(i => i.IsDefault)
+ .Select(i => i.Id)
+ .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
+ }
+
+ var job = new SyncJob
+ {
+ Id = jobId,
+ Name = request.Name,
+ TargetId = target.Id,
+ UserId = request.UserId,
+ UnwatchedOnly = request.UnwatchedOnly,
+ ItemLimit = request.ItemLimit,
+ RequestedItemIds = request.ItemIds ?? new List<string>(),
+ DateCreated = DateTime.UtcNow,
+ DateLastModified = DateTime.UtcNow,
+ SyncNewContent = request.SyncNewContent,
+ ItemCount = items.Count,
+ Category = request.Category,
+ ParentId = request.ParentId,
+ Quality = request.Quality,
+ Profile = request.Profile,
+ Bitrate = request.Bitrate
+ };
+
+ if (!request.Category.HasValue && request.ItemIds != null)
+ {
+ var requestedItems = request.ItemIds
+ .Select(_libraryManager.GetItemById)
+ .Where(i => i != null);
+
+ // It's just a static list
+ if (!requestedItems.Any(i => i.IsFolder || i is IItemByName))
+ {
+ job.SyncNewContent = false;
+ }
+ }
+
+ await _repo.Create(job).ConfigureAwait(false);
+
+ await processor.EnsureJobItems(job).ConfigureAwait(false);
+
+ // If it already has a converting status then is must have been aborted during conversion
+ var jobItemsResult = GetJobItems(new SyncJobItemQuery
+ {
+ Statuses = new[] { SyncJobItemStatus.Queued, SyncJobItemStatus.Converting },
+ JobId = jobId,
+ AddMetadata = false
+ });
+
+ await processor.SyncJobItems(jobItemsResult.Items, false, new Progress<double>(), CancellationToken.None)
+ .ConfigureAwait(false);
+
+ jobItemsResult = GetJobItems(new SyncJobItemQuery
+ {
+ Statuses = new[] { SyncJobItemStatus.Queued, SyncJobItemStatus.Converting },
+ JobId = jobId,
+ AddMetadata = false
+ });
+
+ var returnResult = new SyncJobCreationResult
+ {
+ Job = GetJob(jobId),
+ JobItems = jobItemsResult.Items.ToList()
+ };
+
+ if (SyncJobCreated != null)
+ {
+ EventHelper.FireEventIfNotNull(SyncJobCreated, this, new GenericEventArgs<SyncJobCreationResult>
+ {
+ Argument = returnResult
+
+ }, _logger);
+ }
+
+ if (returnResult.JobItems.Any(i => i.Status == SyncJobItemStatus.Queued || i.Status == SyncJobItemStatus.Converting))
+ {
+ _taskManager.QueueScheduledTask<SyncConvertScheduledTask>();
+ }
+
+ return returnResult;
+ }
+
+ public async Task UpdateJob(SyncJob job)
+ {
+ // Get fresh from the db and only update the fields that are supported to be changed.
+ var instance = _repo.GetJob(job.Id);
+
+ instance.Name = job.Name;
+ instance.Quality = job.Quality;
+ instance.Profile = job.Profile;
+ instance.UnwatchedOnly = job.UnwatchedOnly;
+ instance.SyncNewContent = job.SyncNewContent;
+ instance.ItemLimit = job.ItemLimit;
+
+ await _repo.Update(instance).ConfigureAwait(false);
+
+ OnSyncJobUpdated(instance);
+ }
+
+ internal void OnSyncJobUpdated(SyncJob job)
+ {
+ if (SyncJobUpdated != null)
+ {
+ EventHelper.FireEventIfNotNull(SyncJobUpdated, this, new GenericEventArgs<SyncJob>
+ {
+ Argument = job
+
+ }, _logger);
+ }
+ }
+
+ internal async Task UpdateSyncJobItemInternal(SyncJobItem jobItem)
+ {
+ await _repo.Update(jobItem).ConfigureAwait(false);
+
+ if (SyncJobUpdated != null)
+ {
+ EventHelper.FireEventIfNotNull(SyncJobItemUpdated, this, new GenericEventArgs<SyncJobItem>
+ {
+ Argument = jobItem
+
+ }, _logger);
+ }
+ }
+
+ internal void OnSyncJobItemCreated(SyncJobItem job)
+ {
+ if (SyncJobUpdated != null)
+ {
+ EventHelper.FireEventIfNotNull(SyncJobItemCreated, this, new GenericEventArgs<SyncJobItem>
+ {
+ Argument = job
+
+ }, _logger);
+ }
+ }
+
+ public async Task<QueryResult<SyncJob>> GetJobs(SyncJobQuery query)
+ {
+ var result = _repo.GetJobs(query);
+
+ foreach (var item in result.Items)
+ {
+ await FillMetadata(item).ConfigureAwait(false);
+ }
+
+ return result;
+ }
+
+ private async Task FillMetadata(SyncJob job)
+ {
+ var user = _userManager.GetUserById(job.UserId);
+
+ if (user == null)
+ {
+ return;
+ }
+
+ var target = GetSyncTargets(job.UserId)
+ .FirstOrDefault(i => string.Equals(i.Id, job.TargetId, StringComparison.OrdinalIgnoreCase));
+
+ if (target != null)
+ {
+ job.TargetName = target.Name;
+ }
+
+ var item = job.RequestedItemIds
+ .Select(_libraryManager.GetItemById)
+ .FirstOrDefault(i => i != null);
+
+ if (item == null)
+ {
+ var processor = GetSyncJobProcessor();
+
+ item = (await processor
+ .GetItemsForSync(job.Category, job.ParentId, job.RequestedItemIds, user, job.UnwatchedOnly).ConfigureAwait(false))
+ .FirstOrDefault();
+ }
+
+ if (item != null)
+ {
+ var hasSeries = item as IHasSeries;
+ if (hasSeries != null)
+ {
+ job.ParentName = hasSeries.SeriesName;
+ }
+
+ var hasAlbumArtist = item as IHasAlbumArtist;
+ if (hasAlbumArtist != null)
+ {
+ job.ParentName = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+ }
+
+ var primaryImage = item.GetImageInfo(ImageType.Primary, 0);
+ var itemWithImage = item;
+
+ if (primaryImage == null)
+ {
+ var parentWithImage = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Primary));
+
+ if (parentWithImage != null)
+ {
+ itemWithImage = parentWithImage;
+ primaryImage = parentWithImage.GetImageInfo(ImageType.Primary, 0);
+ }
+ }
+
+ if (primaryImage != null)
+ {
+ try
+ {
+ job.PrimaryImageTag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Primary);
+ job.PrimaryImageItemId = itemWithImage.Id.ToString("N");
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting image info", ex);
+ }
+ }
+ }
+ }
+
+ private void FillMetadata(SyncJobItem jobItem)
+ {
+ var item = _libraryManager.GetItemById(jobItem.ItemId);
+
+ if (item == null)
+ {
+ return;
+ }
+
+ var primaryImage = item.GetImageInfo(ImageType.Primary, 0);
+ var itemWithImage = item;
+
+ if (primaryImage == null)
+ {
+ var parentWithImage = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Primary));
+
+ if (parentWithImage != null)
+ {
+ itemWithImage = parentWithImage;
+ primaryImage = parentWithImage.GetImageInfo(ImageType.Primary, 0);
+ }
+ }
+
+ if (primaryImage != null)
+ {
+ try
+ {
+ jobItem.PrimaryImageTag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Primary);
+ jobItem.PrimaryImageItemId = itemWithImage.Id.ToString("N");
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error getting image info", ex);
+ }
+ }
+ }
+
+ public async Task CancelJob(string id)
+ {
+ var job = GetJob(id);
+
+ if (job == null)
+ {
+ throw new ArgumentException("Job not found.");
+ }
+
+ await _repo.DeleteJob(id).ConfigureAwait(false);
+
+ var path = GetSyncJobProcessor().GetTemporaryPath(id);
+
+ try
+ {
+ _fileSystem.DeleteDirectory(path, true);
+ }
+ catch (IOException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting directory {0}", ex, path);
+ }
+
+ if (SyncJobCancelled != null)
+ {
+ EventHelper.FireEventIfNotNull(SyncJobCancelled, this, new GenericEventArgs<SyncJob>
+ {
+ Argument = job
+
+ }, _logger);
+ }
+ }
+
+ public SyncJob GetJob(string id)
+ {
+ return _repo.GetJob(id);
+ }
+
+ public IEnumerable<SyncTarget> GetSyncTargets(string userId)
+ {
+ return _providers
+ .SelectMany(i => GetSyncTargets(i, userId))
+ .OrderBy(i => i.Name);
+ }
+
+ private IEnumerable<SyncTarget> GetSyncTargets(ISyncProvider provider)
+ {
+ return provider.GetAllSyncTargets().Select(i => new SyncTarget
+ {
+ Name = i.Name,
+ Id = GetSyncTargetId(provider, i)
+ });
+ }
+
+ private IEnumerable<SyncTarget> GetSyncTargets(ISyncProvider provider, string userId)
+ {
+ return provider.GetSyncTargets(userId).Select(i => new SyncTarget
+ {
+ Name = i.Name,
+ Id = GetSyncTargetId(provider, i)
+ });
+ }
+
+ private string GetSyncTargetId(ISyncProvider provider, SyncTarget target)
+ {
+ var hasUniqueId = provider as IHasUniqueTargetIds;
+
+ if (hasUniqueId != null)
+ {
+ return target.Id;
+ }
+
+ return target.Id;
+ //var providerId = GetSyncProviderId(provider);
+ //return (providerId + "-" + target.Id).GetMD5().ToString("N");
+ }
+
+ private string GetSyncProviderId(ISyncProvider provider)
+ {
+ return provider.GetType().Name.GetMD5().ToString("N");
+ }
+
+ public bool SupportsSync(BaseItem item)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException("item");
+ }
+
+ if (item is Playlist)
+ {
+ return true;
+ }
+
+ if (item is Person)
+ {
+ return false;
+ }
+
+ if (item is Year)
+ {
+ return false;
+ }
+
+ if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(item.MediaType, MediaType.Game, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(item.MediaType, MediaType.Book, StringComparison.OrdinalIgnoreCase))
+ {
+ if (item.LocationType == LocationType.Virtual)
+ {
+ return false;
+ }
+
+ var video = item as Video;
+ if (video != null)
+ {
+ if (video.IsPlaceHolder)
+ {
+ return false;
+ }
+
+ if (video.IsShortcut)
+ {
+ return false;
+ }
+ }
+
+ if (item.SourceType != SourceType.Library)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (item.SourceType == SourceType.Channel)
+ {
+ return BaseItem.ChannelManager.SupportsSync(item.ChannelId);
+ }
+
+ return item.LocationType == LocationType.FileSystem || item is Season;
+ }
+
+ private string GetDefaultName(BaseItem item)
+ {
+ return item.Name;
+ }
+
+ public async Task ReportSyncJobItemTransferred(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ jobItem.Status = SyncJobItemStatus.Synced;
+ jobItem.Progress = 100;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+
+ if (!string.IsNullOrWhiteSpace(jobItem.TemporaryPath))
+ {
+ try
+ {
+ _fileSystem.DeleteDirectory(jobItem.TemporaryPath, true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting temporary job file: {0}", ex, jobItem.OutputPath);
+ }
+ }
+ }
+
+ private SyncJobProcessor GetSyncJobProcessor()
+ {
+ return new SyncJobProcessor(_libraryManager, _repo, this, _logger, _userManager, _tvSeriesManager, _mediaEncoder(), _subtitleEncoder(), _config, _fileSystem, _mediaSourceManager());
+ }
+
+ public SyncJobItem GetJobItem(string id)
+ {
+ return _repo.GetJobItem(id);
+ }
+
+ public QueryResult<SyncJobItem> GetJobItems(SyncJobItemQuery query)
+ {
+ var result = _repo.GetJobItems(query);
+
+ if (query.AddMetadata)
+ {
+ foreach (var item in result.Items)
+ {
+ FillMetadata(item);
+ }
+ }
+
+ return result;
+ }
+
+ private SyncedItem GetJobItemInfo(SyncJobItem jobItem)
+ {
+ var job = _repo.GetJob(jobItem.JobId);
+
+ if (job == null)
+ {
+ _logger.Error("GetJobItemInfo job id {0} no longer exists", jobItem.JobId);
+ return null;
+ }
+
+ var libraryItem = _libraryManager.GetItemById(jobItem.ItemId);
+
+ if (libraryItem == null)
+ {
+ _logger.Error("GetJobItemInfo library item with id {0} no longer exists", jobItem.ItemId);
+ return null;
+ }
+
+ var syncedItem = new SyncedItem
+ {
+ SyncJobId = jobItem.JobId,
+ SyncJobItemId = jobItem.Id,
+ ServerId = _appHost.SystemId,
+ UserId = job.UserId,
+ SyncJobName = job.Name,
+ SyncJobDateCreated = job.DateCreated,
+ AdditionalFiles = jobItem.AdditionalFiles.Select(i => new ItemFileInfo
+ {
+ ImageType = i.ImageType,
+ Name = i.Name,
+ Type = i.Type,
+ Index = i.Index
+
+ }).ToList()
+ };
+
+ var dtoOptions = new DtoOptions();
+
+ // Remove some bloat
+ dtoOptions.Fields.Remove(ItemFields.MediaStreams);
+ dtoOptions.Fields.Remove(ItemFields.IndexOptions);
+ dtoOptions.Fields.Remove(ItemFields.MediaSourceCount);
+ dtoOptions.Fields.Remove(ItemFields.Path);
+ dtoOptions.Fields.Remove(ItemFields.SeriesGenres);
+ dtoOptions.Fields.Remove(ItemFields.Settings);
+ dtoOptions.Fields.Remove(ItemFields.SyncInfo);
+ dtoOptions.Fields.Remove(ItemFields.BasicSyncInfo);
+
+ syncedItem.Item = _dtoService().GetBaseItemDto(libraryItem, dtoOptions);
+
+ var mediaSource = jobItem.MediaSource;
+
+ syncedItem.Item.MediaSources = new List<MediaSourceInfo>();
+
+ syncedItem.OriginalFileName = Path.GetFileName(libraryItem.Path);
+ if (string.IsNullOrWhiteSpace(syncedItem.OriginalFileName))
+ {
+ syncedItem.OriginalFileName = Path.GetFileName(mediaSource.Path);
+ }
+
+ // This will be null for items that are not audio/video
+ if (mediaSource != null)
+ {
+ syncedItem.OriginalFileName = Path.ChangeExtension(syncedItem.OriginalFileName, Path.GetExtension(mediaSource.Path));
+ syncedItem.Item.MediaSources.Add(mediaSource);
+ }
+ if (string.IsNullOrWhiteSpace(syncedItem.OriginalFileName))
+ {
+ syncedItem.OriginalFileName = libraryItem.Name;
+ }
+
+ return syncedItem;
+ }
+
+ public Task ReportOfflineAction(UserAction action)
+ {
+ switch (action.Type)
+ {
+ case UserActionType.PlayedItem:
+ return ReportOfflinePlayedItem(action);
+ default:
+ throw new ArgumentException("Unexpected action type");
+ }
+ }
+
+ private Task ReportOfflinePlayedItem(UserAction action)
+ {
+ var item = _libraryManager.GetItemById(action.ItemId);
+ var userData = _userDataManager.GetUserData(action.UserId, item);
+
+ userData.LastPlayedDate = action.Date;
+ _userDataManager.UpdatePlayState(item, userData, action.PositionTicks);
+
+ return _userDataManager.SaveUserData(new Guid(action.UserId), item, userData, UserDataSaveReason.Import, CancellationToken.None);
+ }
+
+ public async Task<List<SyncedItem>> GetReadySyncItems(string targetId)
+ {
+ var processor = GetSyncJobProcessor();
+
+ await processor.SyncJobItems(targetId, false, new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+
+ var jobItemResult = GetJobItems(new SyncJobItemQuery
+ {
+ TargetId = targetId,
+ Statuses = new[]
+ {
+ SyncJobItemStatus.ReadyToTransfer,
+ SyncJobItemStatus.Transferring
+ }
+ });
+
+ var readyItems = jobItemResult.Items
+ .Select(GetJobItemInfo)
+ .Where(i => i != null)
+ .ToList();
+
+ _logger.Debug("Returning {0} ready sync items for targetId {1}", readyItems.Count, targetId);
+
+ return readyItems;
+ }
+
+ public async Task<SyncDataResponse> SyncData(SyncDataRequest request)
+ {
+ if (request.SyncJobItemIds != null)
+ {
+ return await SyncDataUsingSyncJobItemIds(request).ConfigureAwait(false);
+ }
+
+ var jobItemResult = GetJobItems(new SyncJobItemQuery
+ {
+ TargetId = request.TargetId,
+ Statuses = new[] { SyncJobItemStatus.Synced }
+ });
+
+ var response = new SyncDataResponse();
+
+ foreach (var jobItem in jobItemResult.Items)
+ {
+ var requiresSaving = false;
+ var removeFromDevice = false;
+
+ if (request.LocalItemIds.Contains(jobItem.ItemId, StringComparer.OrdinalIgnoreCase))
+ {
+ var libraryItem = _libraryManager.GetItemById(jobItem.ItemId);
+
+ var job = _repo.GetJob(jobItem.JobId);
+ var user = _userManager.GetUserById(job.UserId);
+
+ if (jobItem.IsMarkedForRemoval)
+ {
+ // Tell the device to remove it since it has been marked for removal
+ _logger.Info("Adding ItemIdsToRemove {0} because IsMarkedForRemoval is set.", jobItem.ItemId);
+ removeFromDevice = true;
+ }
+ else if (user == null)
+ {
+ // Tell the device to remove it since the user is gone now
+ _logger.Info("Adding ItemIdsToRemove {0} because the user is no longer valid.", jobItem.ItemId);
+ removeFromDevice = true;
+ }
+ else if (!IsLibraryItemAvailable(libraryItem))
+ {
+ // Tell the device to remove it since it's no longer available
+ _logger.Info("Adding ItemIdsToRemove {0} because it is no longer available.", jobItem.ItemId);
+ removeFromDevice = true;
+ }
+ else if (job.UnwatchedOnly)
+ {
+ if (libraryItem is Video && libraryItem.IsPlayed(user))
+ {
+ // Tell the device to remove it since it has been played
+ _logger.Info("Adding ItemIdsToRemove {0} because it has been marked played.", jobItem.ItemId);
+ removeFromDevice = true;
+ }
+ }
+ else if (libraryItem != null && libraryItem.DateModified.Ticks != jobItem.ItemDateModifiedTicks && jobItem.ItemDateModifiedTicks > 0)
+ {
+ _logger.Info("Setting status to Queued for {0} because the media has been modified since the original sync.", jobItem.ItemId);
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ requiresSaving = true;
+ }
+ }
+ else
+ {
+ // Content is no longer on the device
+ if (jobItem.IsMarkedForRemoval)
+ {
+ jobItem.Status = SyncJobItemStatus.RemovedFromDevice;
+ }
+ else
+ {
+ _logger.Info("Setting status to Queued for {0} because it is no longer on the device.", jobItem.ItemId);
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ }
+ requiresSaving = true;
+ }
+
+ if (removeFromDevice)
+ {
+ response.ItemIdsToRemove.Add(jobItem.ItemId);
+ jobItem.IsMarkedForRemoval = true;
+ requiresSaving = true;
+ }
+
+ if (requiresSaving)
+ {
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+ }
+
+ // Now check each item that's on the device
+ foreach (var itemId in request.LocalItemIds)
+ {
+ // See if it's already marked for removal
+ if (response.ItemIdsToRemove.Contains(itemId, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // If there isn't a sync job for this item, mark it for removal
+ if (!jobItemResult.Items.Any(i => string.Equals(itemId, i.ItemId, StringComparison.OrdinalIgnoreCase)))
+ {
+ response.ItemIdsToRemove.Add(itemId);
+ }
+ }
+
+ response.ItemIdsToRemove = response.ItemIdsToRemove.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ var itemsOnDevice = request.LocalItemIds
+ .Except(response.ItemIdsToRemove)
+ .ToList();
+
+ SetUserAccess(request, response, itemsOnDevice);
+
+ return response;
+ }
+
+ private async Task<SyncDataResponse> SyncDataUsingSyncJobItemIds(SyncDataRequest request)
+ {
+ var jobItemResult = GetJobItems(new SyncJobItemQuery
+ {
+ TargetId = request.TargetId,
+ Statuses = new[] { SyncJobItemStatus.Synced }
+ });
+
+ var response = new SyncDataResponse();
+
+ foreach (var jobItem in jobItemResult.Items)
+ {
+ var requiresSaving = false;
+ var removeFromDevice = false;
+
+ if (request.SyncJobItemIds.Contains(jobItem.Id, StringComparer.OrdinalIgnoreCase))
+ {
+ var libraryItem = _libraryManager.GetItemById(jobItem.ItemId);
+
+ var job = _repo.GetJob(jobItem.JobId);
+ var user = _userManager.GetUserById(job.UserId);
+
+ if (jobItem.IsMarkedForRemoval)
+ {
+ // Tell the device to remove it since it has been marked for removal
+ _logger.Info("Adding ItemIdsToRemove {0} because IsMarkedForRemoval is set.", jobItem.Id);
+ removeFromDevice = true;
+ }
+ else if (user == null)
+ {
+ // Tell the device to remove it since the user is gone now
+ _logger.Info("Adding ItemIdsToRemove {0} because the user is no longer valid.", jobItem.Id);
+ removeFromDevice = true;
+ }
+ else if (!IsLibraryItemAvailable(libraryItem))
+ {
+ // Tell the device to remove it since it's no longer available
+ _logger.Info("Adding ItemIdsToRemove {0} because it is no longer available.", jobItem.Id);
+ removeFromDevice = true;
+ }
+ else if (job.UnwatchedOnly)
+ {
+ if (libraryItem is Video && libraryItem.IsPlayed(user))
+ {
+ // Tell the device to remove it since it has been played
+ _logger.Info("Adding ItemIdsToRemove {0} because it has been marked played.", jobItem.Id);
+ removeFromDevice = true;
+ }
+ }
+ else if (libraryItem != null && libraryItem.DateModified.Ticks != jobItem.ItemDateModifiedTicks && jobItem.ItemDateModifiedTicks > 0)
+ {
+ _logger.Info("Setting status to Queued for {0} because the media has been modified since the original sync.", jobItem.ItemId);
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ requiresSaving = true;
+ }
+ }
+ else
+ {
+ // Content is no longer on the device
+ if (jobItem.IsMarkedForRemoval)
+ {
+ jobItem.Status = SyncJobItemStatus.RemovedFromDevice;
+ }
+ else
+ {
+ _logger.Info("Setting status to Queued for {0} because it is no longer on the device.", jobItem.Id);
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ }
+ requiresSaving = true;
+ }
+
+ if (removeFromDevice)
+ {
+ response.ItemIdsToRemove.Add(jobItem.Id);
+ jobItem.IsMarkedForRemoval = true;
+ requiresSaving = true;
+ }
+
+ if (requiresSaving)
+ {
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+ }
+ }
+
+ // Now check each item that's on the device
+ foreach (var syncJobItemId in request.SyncJobItemIds)
+ {
+ // See if it's already marked for removal
+ if (response.ItemIdsToRemove.Contains(syncJobItemId, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // If there isn't a sync job for this item, mark it for removal
+ if (!jobItemResult.Items.Any(i => string.Equals(syncJobItemId, i.Id, StringComparison.OrdinalIgnoreCase)))
+ {
+ response.ItemIdsToRemove.Add(syncJobItemId);
+ }
+ }
+
+ response.ItemIdsToRemove = response.ItemIdsToRemove.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ return response;
+ }
+
+ private void SetUserAccess(SyncDataRequest request, SyncDataResponse response, List<string> itemIds)
+ {
+ var users = request.OfflineUserIds
+ .Select(_userManager.GetUserById)
+ .Where(i => i != null)
+ .ToList();
+
+ foreach (var itemId in itemIds)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (item != null)
+ {
+ response.ItemUserAccess[itemId] = users
+ .Where(i => IsUserVisible(item, i))
+ .Select(i => i.Id.ToString("N"))
+ .OrderBy(i => i)
+ .ToList();
+ }
+ }
+ }
+
+ private bool IsUserVisible(BaseItem item, User user)
+ {
+ return item.IsVisibleStandalone(user);
+ }
+
+ private bool IsLibraryItemAvailable(BaseItem item)
+ {
+ if (item == null)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public async Task ReEnableJobItem(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ if (jobItem.Status != SyncJobItemStatus.Failed && jobItem.Status != SyncJobItemStatus.Cancelled)
+ {
+ throw new ArgumentException("Operation is not valid for this job item");
+ }
+
+ jobItem.Status = SyncJobItemStatus.Queued;
+ jobItem.Progress = 0;
+ jobItem.IsMarkedForRemoval = false;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+
+ public async Task CancelItems(string targetId, IEnumerable<string> itemIds)
+ {
+ foreach (var item in itemIds)
+ {
+ var syncJobItemResult = GetJobItems(new SyncJobItemQuery
+ {
+ AddMetadata = false,
+ ItemId = item,
+ TargetId = targetId,
+ Statuses = new[] { SyncJobItemStatus.Queued, SyncJobItemStatus.ReadyToTransfer, SyncJobItemStatus.Converting, SyncJobItemStatus.Synced, SyncJobItemStatus.Failed }
+ });
+
+ foreach (var jobItem in syncJobItemResult.Items)
+ {
+ await CancelJobItem(jobItem.Id).ConfigureAwait(false);
+ }
+ }
+ }
+
+ public async Task CancelJobItem(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ if (jobItem.Status != SyncJobItemStatus.Queued && jobItem.Status != SyncJobItemStatus.ReadyToTransfer && jobItem.Status != SyncJobItemStatus.Converting && jobItem.Status != SyncJobItemStatus.Failed && jobItem.Status != SyncJobItemStatus.Synced && jobItem.Status != SyncJobItemStatus.Transferring)
+ {
+ throw new ArgumentException("Operation is not valid for this job item");
+ }
+
+ if (jobItem.Status != SyncJobItemStatus.Synced)
+ {
+ jobItem.Status = SyncJobItemStatus.Cancelled;
+ }
+
+ jobItem.Progress = 0;
+ jobItem.IsMarkedForRemoval = true;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+
+ var path = processor.GetTemporaryPath(jobItem);
+
+ try
+ {
+ _fileSystem.DeleteDirectory(path, true);
+ }
+ catch (IOException)
+ {
+
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error deleting directory {0}", ex, path);
+ }
+
+ //var jobItemsResult = GetJobItems(new SyncJobItemQuery
+ //{
+ // AddMetadata = false,
+ // JobId = jobItem.JobId,
+ // Limit = 0,
+ // Statuses = new[] { SyncJobItemStatus.Converting, SyncJobItemStatus.Failed, SyncJobItemStatus.Queued, SyncJobItemStatus.ReadyToTransfer, SyncJobItemStatus.Synced, SyncJobItemStatus.Transferring }
+ //});
+
+ //if (jobItemsResult.TotalRecordCount == 0)
+ //{
+ // await CancelJob(jobItem.JobId).ConfigureAwait(false);
+ //}
+ }
+
+ public Task MarkJobItemForRemoval(string id)
+ {
+ return CancelJobItem(id);
+ }
+
+ public async Task UnmarkJobItemForRemoval(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ if (jobItem.Status != SyncJobItemStatus.Synced)
+ {
+ throw new ArgumentException("Operation is not valid for this job item");
+ }
+
+ jobItem.IsMarkedForRemoval = false;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+
+ public async Task ReportSyncJobItemTransferBeginning(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ jobItem.Status = SyncJobItemStatus.Transferring;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+
+ public async Task ReportSyncJobItemTransferFailed(string id)
+ {
+ var jobItem = _repo.GetJobItem(id);
+
+ jobItem.Status = SyncJobItemStatus.ReadyToTransfer;
+
+ await UpdateSyncJobItemInternal(jobItem).ConfigureAwait(false);
+
+ var processor = GetSyncJobProcessor();
+
+ await processor.UpdateJobStatus(jobItem.JobId).ConfigureAwait(false);
+ }
+
+ public Dictionary<string, SyncedItemProgress> GetSyncedItemProgresses(SyncJobItemQuery query)
+ {
+ return _repo.GetSyncedItemProgresses(query);
+ }
+
+ public SyncJobOptions GetAudioOptions(SyncJobItem jobItem, SyncJob job)
+ {
+ var options = GetSyncJobOptions(jobItem.TargetId, null, null);
+
+ if (job.Bitrate.HasValue)
+ {
+ options.DeviceProfile.MaxStaticBitrate = job.Bitrate.Value;
+ }
+
+ return options;
+ }
+
+ public ISyncProvider GetSyncProvider(SyncJobItem jobItem)
+ {
+ foreach (var provider in _providers)
+ {
+ foreach (var target in GetSyncTargets(provider))
+ {
+ if (string.Equals(target.Id, jobItem.TargetId, StringComparison.OrdinalIgnoreCase))
+ {
+ return provider;
+ }
+ }
+ }
+ return null;
+ }
+
+ public SyncJobOptions GetVideoOptions(SyncJobItem jobItem, SyncJob job)
+ {
+ var options = GetSyncJobOptions(jobItem.TargetId, job.Profile, job.Quality);
+
+ if (job.Bitrate.HasValue)
+ {
+ options.DeviceProfile.MaxStaticBitrate = job.Bitrate.Value;
+ }
+
+ return options;
+ }
+
+ private SyncJobOptions GetSyncJobOptions(string targetId, string profile, string quality)
+ {
+ foreach (var provider in _providers)
+ {
+ foreach (var target in GetSyncTargets(provider))
+ {
+ if (string.Equals(target.Id, targetId, StringComparison.OrdinalIgnoreCase))
+ {
+ return GetSyncJobOptions(provider, target, profile, quality);
+ }
+ }
+ }
+
+ return GetDefaultSyncJobOptions(profile, quality);
+ }
+
+ private SyncJobOptions GetSyncJobOptions(ISyncProvider provider, SyncTarget target, string profile, string quality)
+ {
+ var hasProfile = provider as IHasSyncQuality;
+
+ if (hasProfile != null)
+ {
+ return hasProfile.GetSyncJobOptions(target, profile, quality);
+ }
+
+ return GetDefaultSyncJobOptions(profile, quality);
+ }
+
+ private SyncJobOptions GetDefaultSyncJobOptions(string profile, string quality)
+ {
+ var supportsAc3 = string.Equals(profile, "general", StringComparison.OrdinalIgnoreCase);
+
+ var deviceProfile = new CloudSyncProfile(supportsAc3, false);
+ deviceProfile.MaxStaticBitrate = SyncHelper.AdjustBitrate(deviceProfile.MaxStaticBitrate, quality);
+
+ return new SyncJobOptions
+ {
+ DeviceProfile = deviceProfile,
+ IsConverting = IsConverting(profile, quality)
+ };
+ }
+
+ private bool IsConverting(string profile, string quality)
+ {
+ return !string.Equals(profile, "original", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public IEnumerable<SyncQualityOption> GetQualityOptions(string targetId)
+ {
+ return GetQualityOptions(targetId, null);
+ }
+
+ public IEnumerable<SyncQualityOption> GetQualityOptions(string targetId, User user)
+ {
+ foreach (var provider in _providers)
+ {
+ foreach (var target in GetSyncTargets(provider))
+ {
+ if (string.Equals(target.Id, targetId, StringComparison.OrdinalIgnoreCase))
+ {
+ return GetQualityOptions(provider, target, user);
+ }
+ }
+ }
+
+ return new List<SyncQualityOption>();
+ }
+
+ private IEnumerable<SyncQualityOption> GetQualityOptions(ISyncProvider provider, SyncTarget target, User user)
+ {
+ var hasQuality = provider as IHasSyncQuality;
+ if (hasQuality != null)
+ {
+ var options = hasQuality.GetQualityOptions(target);
+
+ if (user != null && !user.Policy.EnableSyncTranscoding)
+ {
+ options = options.Where(i => i.IsOriginalQuality);
+ }
+
+ return options;
+ }
+
+ // Default options for providers that don't override
+ return new List<SyncQualityOption>
+ {
+ new SyncQualityOption
+ {
+ Name = "High",
+ Id = "high",
+ IsDefault = true
+ },
+ new SyncQualityOption
+ {
+ Name = "Medium",
+ Id = "medium"
+ },
+ new SyncQualityOption
+ {
+ Name = "Low",
+ Id = "low"
+ },
+ new SyncQualityOption
+ {
+ Name = "Custom",
+ Id = "custom"
+ }
+ };
+ }
+
+ public IEnumerable<SyncProfileOption> GetProfileOptions(string targetId, User user)
+ {
+ foreach (var provider in _providers)
+ {
+ foreach (var target in GetSyncTargets(provider))
+ {
+ if (string.Equals(target.Id, targetId, StringComparison.OrdinalIgnoreCase))
+ {
+ return GetProfileOptions(provider, target, user);
+ }
+ }
+ }
+
+ return new List<SyncProfileOption>();
+ }
+
+ public IEnumerable<SyncProfileOption> GetProfileOptions(string targetId)
+ {
+ return GetProfileOptions(targetId, null);
+ }
+
+ private IEnumerable<SyncProfileOption> GetProfileOptions(ISyncProvider provider, SyncTarget target, User user)
+ {
+ var hasQuality = provider as IHasSyncQuality;
+ if (hasQuality != null)
+ {
+ return hasQuality.GetProfileOptions(target);
+ }
+
+ var list = new List<SyncProfileOption>();
+
+ list.Add(new SyncProfileOption
+ {
+ Name = "Original",
+ Id = "Original",
+ Description = "Syncs original files as-is.",
+ EnableQualityOptions = false
+ });
+
+ if (user == null || user.Policy.EnableSyncTranscoding)
+ {
+ list.Add(new SyncProfileOption
+ {
+ Name = "Baseline",
+ Id = "baseline",
+ Description = "Designed for compatibility with all devices, including web browsers. Targets H264/AAC video and MP3 audio."
+ });
+
+ list.Add(new SyncProfileOption
+ {
+ Name = "General",
+ Id = "general",
+ Description = "Designed for compatibility with Chromecast, Roku, Smart TV's, and other similar devices. Targets H264/AAC/AC3 video and MP3 audio.",
+ IsDefault = true
+ });
+ }
+
+ return list;
+ }
+
+ protected internal void OnConversionComplete(SyncJobItem item)
+ {
+ var syncProvider = GetSyncProvider(item);
+ if (syncProvider is AppSyncProvider)
+ {
+ return;
+ }
+
+ _taskManager.QueueIfNotRunning<ServerSyncScheduledTask>();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncNotificationEntryPoint.cs b/Emby.Server.Implementations/Sync/SyncNotificationEntryPoint.cs
new file mode 100644
index 000000000..46cdb28a4
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncNotificationEntryPoint.cs
@@ -0,0 +1,48 @@
+using System.Threading;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Sync;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncNotificationEntryPoint : IServerEntryPoint
+ {
+ private readonly ISessionManager _sessionManager;
+ private readonly ISyncManager _syncManager;
+
+ public SyncNotificationEntryPoint(ISyncManager syncManager, ISessionManager sessionManager)
+ {
+ _syncManager = syncManager;
+ _sessionManager = sessionManager;
+ }
+
+ public void Run()
+ {
+ _syncManager.SyncJobItemUpdated += _syncManager_SyncJobItemUpdated;
+ }
+
+ private async void _syncManager_SyncJobItemUpdated(object sender, GenericEventArgs<SyncJobItem> e)
+ {
+ var item = e.Argument;
+
+ if (item.Status == SyncJobItemStatus.ReadyToTransfer)
+ {
+ try
+ {
+ await _sessionManager.SendMessageToUserDeviceSessions(item.TargetId, "SyncJobItemReady", item, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _syncManager.SyncJobItemUpdated -= _syncManager_SyncJobItemUpdated;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncRegistrationInfo.cs b/Emby.Server.Implementations/Sync/SyncRegistrationInfo.cs
new file mode 100644
index 000000000..c2658c5c5
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncRegistrationInfo.cs
@@ -0,0 +1,31 @@
+using MediaBrowser.Common.Security;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncRegistrationInfo : IRequiresRegistration
+ {
+ private readonly ISecurityManager _securityManager;
+
+ public static SyncRegistrationInfo Instance;
+
+ public SyncRegistrationInfo(ISecurityManager securityManager)
+ {
+ _securityManager = securityManager;
+ Instance = this;
+ }
+
+ private bool _registered;
+ public bool IsRegistered
+ {
+ get { return _registered; }
+ }
+
+ public async Task LoadRegistrationInfoAsync()
+ {
+ var info = await _securityManager.GetRegistrationStatus("sync").ConfigureAwait(false);
+
+ _registered = info.IsValid;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncRepository.cs b/Emby.Server.Implementations/Sync/SyncRepository.cs
new file mode 100644
index 000000000..885f8e64a
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncRepository.cs
@@ -0,0 +1,820 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Sync;
+using SQLitePCL.pretty;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncRepository : BaseSqliteRepository, ISyncRepository
+ {
+ private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+ private readonly IJsonSerializer _json;
+
+ public SyncRepository(ILogger logger, IJsonSerializer json, IServerApplicationPaths appPaths)
+ : base(logger)
+ {
+ _json = json;
+ DbFilePath = Path.Combine(appPaths.DataPath, "sync14.db");
+ }
+
+ private class SyncSummary
+ {
+ public Dictionary<string, int> Items { get; set; }
+
+ public SyncSummary()
+ {
+ Items = new Dictionary<string, int>();
+ }
+ }
+
+ public void Initialize()
+ {
+ using (var connection = CreateConnection())
+ {
+ RunDefaultInitialization(connection);
+
+ string[] queries = {
+
+ "create table if not exists SyncJobs (Id GUID PRIMARY KEY, TargetId TEXT NOT NULL, Name TEXT NOT NULL, Profile TEXT, Quality TEXT, Bitrate INT, Status TEXT NOT NULL, Progress FLOAT, UserId TEXT NOT NULL, ItemIds TEXT NOT NULL, Category TEXT, ParentId TEXT, UnwatchedOnly BIT, ItemLimit INT, SyncNewContent BIT, DateCreated DateTime, DateLastModified DateTime, ItemCount int)",
+
+ "create table if not exists SyncJobItems (Id GUID PRIMARY KEY, ItemId TEXT, ItemName TEXT, MediaSourceId TEXT, JobId TEXT, TemporaryPath TEXT, OutputPath TEXT, Status TEXT, TargetId TEXT, DateCreated DateTime, Progress FLOAT, AdditionalFiles TEXT, MediaSource TEXT, IsMarkedForRemoval BIT, JobItemIndex INT, ItemDateModifiedTicks BIGINT)",
+
+ "drop index if exists idx_SyncJobItems2",
+ "drop index if exists idx_SyncJobItems3",
+ "drop index if exists idx_SyncJobs1",
+ "drop index if exists idx_SyncJobs",
+ "drop index if exists idx_SyncJobItems1",
+ "create index if not exists idx_SyncJobItems4 on SyncJobItems(TargetId,ItemId,Status,Progress,DateCreated)",
+ "create index if not exists idx_SyncJobItems5 on SyncJobItems(TargetId,Status,ItemId,Progress)",
+
+ "create index if not exists idx_SyncJobs2 on SyncJobs(TargetId,Status,ItemIds,Progress)",
+
+ "pragma shrink_memory"
+ };
+
+ connection.RunQueries(queries);
+
+ connection.RunInTransaction(db =>
+ {
+ var existingColumnNames = GetColumnNames(db, "SyncJobs");
+ AddColumn(db, "SyncJobs", "Profile", "TEXT", existingColumnNames);
+ AddColumn(db, "SyncJobs", "Bitrate", "INT", existingColumnNames);
+
+ existingColumnNames = GetColumnNames(db, "SyncJobItems");
+ AddColumn(db, "SyncJobItems", "ItemDateModifiedTicks", "BIGINT", existingColumnNames);
+ }, TransactionMode);
+ }
+ }
+
+ protected override bool EnableTempStoreMemory
+ {
+ get
+ {
+ return true;
+ }
+ }
+
+ private const string BaseJobSelectText = "select Id, TargetId, Name, Profile, Quality, Bitrate, Status, Progress, UserId, ItemIds, Category, ParentId, UnwatchedOnly, ItemLimit, SyncNewContent, DateCreated, DateLastModified, ItemCount from SyncJobs";
+ private const string BaseJobItemSelectText = "select Id, ItemId, ItemName, MediaSourceId, JobId, TemporaryPath, OutputPath, Status, TargetId, DateCreated, Progress, AdditionalFiles, MediaSource, IsMarkedForRemoval, JobItemIndex, ItemDateModifiedTicks from SyncJobItems";
+
+ public SyncJob GetJob(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ CheckDisposed();
+
+ var guid = new Guid(id);
+
+ if (guid == Guid.Empty)
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = BaseJobSelectText + " where Id=?";
+ var paramList = new List<object>();
+
+ paramList.Add(guid.ToGuidParamValue());
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ return GetJob(row);
+ }
+
+ return null;
+ }
+ }
+ }
+
+ private SyncJob GetJob(IReadOnlyList<IResultSetValue> reader)
+ {
+ var info = new SyncJob
+ {
+ Id = reader[0].ReadGuid().ToString("N"),
+ TargetId = reader[1].ToString(),
+ Name = reader[2].ToString()
+ };
+
+ if (reader[3].SQLiteType != SQLiteType.Null)
+ {
+ info.Profile = reader[3].ToString();
+ }
+
+ if (reader[4].SQLiteType != SQLiteType.Null)
+ {
+ info.Quality = reader[4].ToString();
+ }
+
+ if (reader[5].SQLiteType != SQLiteType.Null)
+ {
+ info.Bitrate = reader[5].ToInt();
+ }
+
+ if (reader[6].SQLiteType != SQLiteType.Null)
+ {
+ info.Status = (SyncJobStatus)Enum.Parse(typeof(SyncJobStatus), reader[6].ToString(), true);
+ }
+
+ if (reader[7].SQLiteType != SQLiteType.Null)
+ {
+ info.Progress = reader[7].ToDouble();
+ }
+
+ if (reader[8].SQLiteType != SQLiteType.Null)
+ {
+ info.UserId = reader[8].ToString();
+ }
+
+ if (reader[9].SQLiteType != SQLiteType.Null)
+ {
+ info.RequestedItemIds = reader[9].ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList();
+ }
+
+ if (reader[10].SQLiteType != SQLiteType.Null)
+ {
+ info.Category = (SyncCategory)Enum.Parse(typeof(SyncCategory), reader[10].ToString(), true);
+ }
+
+ if (reader[11].SQLiteType != SQLiteType.Null)
+ {
+ info.ParentId = reader[11].ToString();
+ }
+
+ if (reader[12].SQLiteType != SQLiteType.Null)
+ {
+ info.UnwatchedOnly = reader[12].ToBool();
+ }
+
+ if (reader[13].SQLiteType != SQLiteType.Null)
+ {
+ info.ItemLimit = reader[13].ToInt();
+ }
+
+ info.SyncNewContent = reader[14].ToBool();
+
+ info.DateCreated = reader[15].ReadDateTime();
+ info.DateLastModified = reader[16].ReadDateTime();
+ info.ItemCount = reader[17].ToInt();
+
+ return info;
+ }
+
+ public Task Create(SyncJob job)
+ {
+ return InsertOrUpdate(job, true);
+ }
+
+ public Task Update(SyncJob job)
+ {
+ return InsertOrUpdate(job, false);
+ }
+
+ private async Task InsertOrUpdate(SyncJob job, bool insert)
+ {
+ if (job == null)
+ {
+ throw new ArgumentNullException("job");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ string commandText;
+ var paramList = new List<object>();
+
+ if (insert)
+ {
+ commandText = "insert into SyncJobs (Id, TargetId, Name, Profile, Quality, Bitrate, Status, Progress, UserId, ItemIds, Category, ParentId, UnwatchedOnly, ItemLimit, SyncNewContent, DateCreated, DateLastModified, ItemCount) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ }
+ else
+ {
+ commandText = "update SyncJobs set TargetId=?,Name=?,Profile=?,Quality=?,Bitrate=?,Status=?,Progress=?,UserId=?,ItemIds=?,Category=?,ParentId=?,UnwatchedOnly=?,ItemLimit=?,SyncNewContent=?,DateCreated=?,DateLastModified=?,ItemCount=? where Id=?";
+ }
+
+ paramList.Add(job.TargetId);
+ paramList.Add(job.Name);
+ paramList.Add(job.Profile);
+ paramList.Add(job.Quality);
+ paramList.Add(job.Bitrate);
+ paramList.Add(job.Status.ToString());
+ paramList.Add(job.Progress);
+ paramList.Add(job.UserId);
+
+ paramList.Add(string.Join(",", job.RequestedItemIds.ToArray()));
+ paramList.Add(job.Category);
+ paramList.Add(job.ParentId);
+ paramList.Add(job.UnwatchedOnly);
+ paramList.Add(job.ItemLimit);
+ paramList.Add(job.SyncNewContent);
+ paramList.Add(job.DateCreated.ToDateTimeParamValue());
+ paramList.Add(job.DateLastModified.ToDateTimeParamValue());
+ paramList.Add(job.ItemCount);
+
+ if (insert)
+ {
+ paramList.Insert(0, job.Id.ToGuidParamValue());
+ }
+ else
+ {
+ paramList.Add(job.Id.ToGuidParamValue());
+ }
+
+ connection.RunInTransaction(conn =>
+ {
+ conn.Execute(commandText, paramList.ToArray());
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public async Task DeleteJob(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ connection.RunInTransaction(conn =>
+ {
+ conn.Execute("delete from SyncJobs where Id=?", id.ToGuidParamValue());
+ conn.Execute("delete from SyncJobItems where JobId=?", id);
+ }, TransactionMode);
+ }
+ }
+ }
+
+ public QueryResult<SyncJob> GetJobs(SyncJobQuery query)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = BaseJobSelectText;
+ var paramList = new List<object>();
+
+ var whereClauses = new List<string>();
+
+ if (query.Statuses.Length > 0)
+ {
+ var statuses = string.Join(",", query.Statuses.Select(i => "'" + i.ToString() + "'").ToArray());
+
+ whereClauses.Add(string.Format("Status in ({0})", statuses));
+ }
+ if (!string.IsNullOrWhiteSpace(query.TargetId))
+ {
+ whereClauses.Add("TargetId=?");
+ paramList.Add(query.TargetId);
+ }
+ if (!string.IsNullOrWhiteSpace(query.ExcludeTargetIds))
+ {
+ var excludeIds = (query.ExcludeTargetIds ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+ if (excludeIds.Length == 1)
+ {
+ whereClauses.Add("TargetId<>?");
+ paramList.Add(excludeIds[0]);
+ }
+ else if (excludeIds.Length > 1)
+ {
+ whereClauses.Add("TargetId<>?");
+ paramList.Add(excludeIds[0]);
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(query.UserId))
+ {
+ whereClauses.Add("UserId=?");
+ paramList.Add(query.UserId);
+ }
+ if (query.SyncNewContent.HasValue)
+ {
+ whereClauses.Add("SyncNewContent=?");
+ paramList.Add(query.SyncNewContent.Value);
+ }
+
+ commandText += " mainTable";
+
+ var whereTextWithoutPaging = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ var startIndex = query.StartIndex ?? 0;
+ if (startIndex > 0)
+ {
+ whereClauses.Add(string.Format("Id NOT IN (SELECT Id FROM SyncJobs ORDER BY (Select Max(DateLastModified) from SyncJobs where TargetId=mainTable.TargetId) DESC, DateLastModified DESC LIMIT {0})",
+ startIndex.ToString(_usCulture)));
+ }
+
+ if (whereClauses.Count > 0)
+ {
+ commandText += " where " + string.Join(" AND ", whereClauses.ToArray());
+ }
+
+ commandText += " ORDER BY (Select Max(DateLastModified) from SyncJobs where TargetId=mainTable.TargetId) DESC, DateLastModified DESC";
+
+ if (query.Limit.HasValue)
+ {
+ commandText += " LIMIT " + query.Limit.Value.ToString(_usCulture);
+ }
+
+ var list = new List<SyncJob>();
+ var count = connection.Query("select count (Id) from SyncJobs" + whereTextWithoutPaging, paramList.ToArray())
+ .SelectScalarInt()
+ .First();
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ list.Add(GetJob(row));
+ }
+
+ return new QueryResult<SyncJob>()
+ {
+ Items = list.ToArray(),
+ TotalRecordCount = count
+ };
+ }
+ }
+ }
+
+ public SyncJobItem GetJobItem(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ throw new ArgumentNullException("id");
+ }
+
+ CheckDisposed();
+
+ var guid = new Guid(id);
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = BaseJobItemSelectText + " where Id=?";
+ var paramList = new List<object>();
+
+ paramList.Add(guid.ToGuidParamValue());
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ return GetJobItem(row);
+ }
+
+ return null;
+ }
+ }
+ }
+
+ private QueryResult<T> GetJobItemReader<T>(SyncJobItemQuery query, string baseSelectText, Func<IReadOnlyList<IResultSetValue>, T> itemFactory)
+ {
+ if (query == null)
+ {
+ throw new ArgumentNullException("query");
+ }
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = baseSelectText;
+ var paramList = new List<object>();
+
+ var whereClauses = new List<string>();
+
+ if (!string.IsNullOrWhiteSpace(query.JobId))
+ {
+ whereClauses.Add("JobId=?");
+ paramList.Add(query.JobId);
+ }
+ if (!string.IsNullOrWhiteSpace(query.ItemId))
+ {
+ whereClauses.Add("ItemId=?");
+ paramList.Add(query.ItemId);
+ }
+ if (!string.IsNullOrWhiteSpace(query.TargetId))
+ {
+ whereClauses.Add("TargetId=?");
+ paramList.Add(query.TargetId);
+ }
+
+ if (query.Statuses.Length > 0)
+ {
+ var statuses = string.Join(",", query.Statuses.Select(i => "'" + i.ToString() + "'").ToArray());
+
+ whereClauses.Add(string.Format("Status in ({0})", statuses));
+ }
+
+ var whereTextWithoutPaging = whereClauses.Count == 0 ?
+ string.Empty :
+ " where " + string.Join(" AND ", whereClauses.ToArray());
+
+ var startIndex = query.StartIndex ?? 0;
+ if (startIndex > 0)
+ {
+ whereClauses.Add(string.Format("Id NOT IN (SELECT Id FROM SyncJobItems ORDER BY JobItemIndex, DateCreated LIMIT {0})",
+ startIndex.ToString(_usCulture)));
+ }
+
+ if (whereClauses.Count > 0)
+ {
+ commandText += " where " + string.Join(" AND ", whereClauses.ToArray());
+ }
+
+ commandText += " ORDER BY JobItemIndex, DateCreated";
+
+ if (query.Limit.HasValue)
+ {
+ commandText += " LIMIT " + query.Limit.Value.ToString(_usCulture);
+ }
+
+ var list = new List<T>();
+ var count = connection.Query("select count (Id) from SyncJobItems" + whereTextWithoutPaging, paramList.ToArray())
+ .SelectScalarInt()
+ .First();
+
+ foreach (var row in connection.Query(commandText, paramList.ToArray()))
+ {
+ list.Add(itemFactory(row));
+ }
+
+ return new QueryResult<T>()
+ {
+ Items = list.ToArray(),
+ TotalRecordCount = count
+ };
+ }
+ }
+ }
+
+ public Dictionary<string, SyncedItemProgress> GetSyncedItemProgresses(SyncJobItemQuery query)
+ {
+ var result = new Dictionary<string, SyncedItemProgress>();
+
+ var now = DateTime.UtcNow;
+
+ using (WriteLock.Read())
+ {
+ using (var connection = CreateConnection(true))
+ {
+ var commandText = "select ItemId,Status,Progress from SyncJobItems";
+ var whereClauses = new List<string>();
+
+ if (!string.IsNullOrWhiteSpace(query.TargetId))
+ {
+ whereClauses.Add("TargetId=@TargetId");
+ }
+
+ if (query.Statuses.Length > 0)
+ {
+ var statuses = string.Join(",", query.Statuses.Select(i => "'" + i.ToString() + "'").ToArray());
+
+ whereClauses.Add(string.Format("Status in ({0})", statuses));
+ }
+
+ if (whereClauses.Count > 0)
+ {
+ commandText += " where " + string.Join(" AND ", whereClauses.ToArray());
+ }
+
+ var statementTexts = new List<string>
+ {
+ commandText
+ };
+
+ commandText = commandText
+ .Replace("select ItemId,Status,Progress from SyncJobItems", "select ItemIds,Status,Progress from SyncJobs")
+ .Replace("'Synced'", "'Completed','CompletedWithError'");
+
+ statementTexts.Add(commandText);
+
+ var statements = connection.PrepareAll(string.Join(";", statementTexts.ToArray()))
+ .ToList();
+
+ using (var statement = statements[0])
+ {
+ if (!string.IsNullOrWhiteSpace(query.TargetId))
+ {
+ statement.TryBind("@TargetId", query.TargetId);
+ }
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ AddStatusResult(row, result, false);
+ }
+ LogQueryTime("GetSyncedItemProgresses", commandText, now);
+ }
+
+ now = DateTime.UtcNow;
+
+ using (var statement = statements[1])
+ {
+ if (!string.IsNullOrWhiteSpace(query.TargetId))
+ {
+ statement.TryBind("@TargetId", query.TargetId);
+ }
+
+ foreach (var row in statement.ExecuteQuery())
+ {
+ AddStatusResult(row, result, true);
+ }
+ LogQueryTime("GetSyncedItemProgresses", commandText, now);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private void LogQueryTime(string methodName, string commandText, DateTime startDate)
+ {
+ var elapsed = (DateTime.UtcNow - startDate).TotalMilliseconds;
+
+ var slowThreshold = 1000;
+
+#if DEBUG
+ slowThreshold = 50;
+#endif
+
+ if (elapsed >= slowThreshold)
+ {
+ Logger.Debug("{2} query time (slow): {0}ms. Query: {1}",
+ Convert.ToInt32(elapsed),
+ commandText,
+ methodName);
+ }
+ else
+ {
+ //Logger.Debug("{2} query time: {0}ms. Query: {1}",
+ // Convert.ToInt32(elapsed),
+ // cmd.CommandText,
+ // methodName);
+ }
+ }
+
+ private void AddStatusResult(IReadOnlyList<IResultSetValue> reader, Dictionary<string, SyncedItemProgress> result, bool multipleIds)
+ {
+ if (reader[0].SQLiteType == SQLiteType.Null)
+ {
+ return;
+ }
+
+ var itemIds = new List<string>();
+
+ var ids = reader[0].ToString();
+
+ if (multipleIds)
+ {
+ itemIds = ids.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList();
+ }
+ else
+ {
+ itemIds.Add(ids);
+ }
+
+ if (reader[1].SQLiteType != SQLiteType.Null)
+ {
+ SyncJobItemStatus status;
+ var statusString = reader[1].ToString();
+ if (string.Equals(statusString, "Completed", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(statusString, "CompletedWithError", StringComparison.OrdinalIgnoreCase))
+ {
+ status = SyncJobItemStatus.Synced;
+ }
+ else
+ {
+ status = (SyncJobItemStatus)Enum.Parse(typeof(SyncJobItemStatus), statusString, true);
+ }
+
+ if (status == SyncJobItemStatus.Synced)
+ {
+ foreach (var itemId in itemIds)
+ {
+ result[itemId] = new SyncedItemProgress
+ {
+ Status = SyncJobItemStatus.Synced
+ };
+ }
+ }
+ else
+ {
+ double progress = reader[2].SQLiteType == SQLiteType.Null ? 0.0 : reader[2].ToDouble();
+
+ foreach (var itemId in itemIds)
+ {
+ SyncedItemProgress currentStatus;
+ if (!result.TryGetValue(itemId, out currentStatus) || (currentStatus.Status != SyncJobItemStatus.Synced && progress >= currentStatus.Progress))
+ {
+ result[itemId] = new SyncedItemProgress
+ {
+ Status = status,
+ Progress = progress
+ };
+ }
+ }
+ }
+ }
+ }
+
+ public QueryResult<SyncJobItem> GetJobItems(SyncJobItemQuery query)
+ {
+ return GetJobItemReader(query, BaseJobItemSelectText, GetJobItem);
+ }
+
+ public Task Create(SyncJobItem jobItem)
+ {
+ return InsertOrUpdate(jobItem, true);
+ }
+
+ public Task Update(SyncJobItem jobItem)
+ {
+ return InsertOrUpdate(jobItem, false);
+ }
+
+ private async Task InsertOrUpdate(SyncJobItem jobItem, bool insert)
+ {
+ if (jobItem == null)
+ {
+ throw new ArgumentNullException("jobItem");
+ }
+
+ CheckDisposed();
+
+ using (WriteLock.Write())
+ {
+ using (var connection = CreateConnection())
+ {
+ string commandText;
+
+ if (insert)
+ {
+ commandText = "insert into SyncJobItems (Id, ItemId, ItemName, MediaSourceId, JobId, TemporaryPath, OutputPath, Status, TargetId, DateCreated, Progress, AdditionalFiles, MediaSource, IsMarkedForRemoval, JobItemIndex, ItemDateModifiedTicks) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ }
+ else
+ {
+ // cmd
+ commandText = "update SyncJobItems set ItemId=?,ItemName=?,MediaSourceId=?,JobId=?,TemporaryPath=?,OutputPath=?,Status=?,TargetId=?,DateCreated=?,Progress=?,AdditionalFiles=?,MediaSource=?,IsMarkedForRemoval=?,JobItemIndex=?,ItemDateModifiedTicks=? where Id=?";
+ }
+
+ var paramList = new List<object>();
+ paramList.Add(jobItem.ItemId);
+ paramList.Add(jobItem.ItemName);
+ paramList.Add(jobItem.MediaSourceId);
+ paramList.Add(jobItem.JobId);
+ paramList.Add(jobItem.TemporaryPath);
+ paramList.Add(jobItem.OutputPath);
+ paramList.Add(jobItem.Status.ToString());
+
+ paramList.Add(jobItem.TargetId);
+ paramList.Add(jobItem.DateCreated.ToDateTimeParamValue());
+ paramList.Add(jobItem.Progress);
+ paramList.Add(_json.SerializeToString(jobItem.AdditionalFiles));
+ paramList.Add(jobItem.MediaSource == null ? null : _json.SerializeToString(jobItem.MediaSource));
+ paramList.Add(jobItem.IsMarkedForRemoval);
+ paramList.Add(jobItem.JobItemIndex);
+ paramList.Add(jobItem.ItemDateModifiedTicks);
+
+ if (insert)
+ {
+ paramList.Insert(0, jobItem.Id.ToGuidParamValue());
+ }
+ else
+ {
+ paramList.Add(jobItem.Id.ToGuidParamValue());
+ }
+
+ connection.RunInTransaction(conn =>
+ {
+ conn.Execute(commandText, paramList.ToArray());
+ }, TransactionMode);
+ }
+ }
+ }
+
+ private SyncJobItem GetJobItem(IReadOnlyList<IResultSetValue> reader)
+ {
+ var info = new SyncJobItem
+ {
+ Id = reader[0].ReadGuid().ToString("N"),
+ ItemId = reader[1].ToString()
+ };
+
+ if (reader[2].SQLiteType != SQLiteType.Null)
+ {
+ info.ItemName = reader[2].ToString();
+ }
+
+ if (reader[3].SQLiteType != SQLiteType.Null)
+ {
+ info.MediaSourceId = reader[3].ToString();
+ }
+
+ info.JobId = reader[4].ToString();
+
+ if (reader[5].SQLiteType != SQLiteType.Null)
+ {
+ info.TemporaryPath = reader[5].ToString();
+ }
+ if (reader[6].SQLiteType != SQLiteType.Null)
+ {
+ info.OutputPath = reader[6].ToString();
+ }
+
+ if (reader[7].SQLiteType != SQLiteType.Null)
+ {
+ info.Status = (SyncJobItemStatus)Enum.Parse(typeof(SyncJobItemStatus), reader[7].ToString(), true);
+ }
+
+ info.TargetId = reader[8].ToString();
+
+ info.DateCreated = reader[9].ReadDateTime();
+
+ if (reader[10].SQLiteType != SQLiteType.Null)
+ {
+ info.Progress = reader[10].ToDouble();
+ }
+
+ if (reader[11].SQLiteType != SQLiteType.Null)
+ {
+ var json = reader[11].ToString();
+
+ if (!string.IsNullOrWhiteSpace(json))
+ {
+ info.AdditionalFiles = _json.DeserializeFromString<List<ItemFileInfo>>(json);
+ }
+ }
+
+ if (reader[12].SQLiteType != SQLiteType.Null)
+ {
+ var json = reader[12].ToString();
+
+ if (!string.IsNullOrWhiteSpace(json))
+ {
+ info.MediaSource = _json.DeserializeFromString<MediaSourceInfo>(json);
+ }
+ }
+
+ info.IsMarkedForRemoval = reader[13].ToBool();
+ info.JobItemIndex = reader[14].ToInt();
+
+ if (reader[15].SQLiteType != SQLiteType.Null)
+ {
+ info.ItemDateModifiedTicks = reader[15].ToInt64();
+ }
+
+ return info;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/SyncedMediaSourceProvider.cs b/Emby.Server.Implementations/Sync/SyncedMediaSourceProvider.cs
new file mode 100644
index 000000000..1e54885e6
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/SyncedMediaSourceProvider.cs
@@ -0,0 +1,158 @@
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class SyncedMediaSourceProvider : IMediaSourceProvider
+ {
+ private readonly SyncManager _syncManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ILogger _logger;
+
+ public SyncedMediaSourceProvider(ISyncManager syncManager, IServerApplicationHost appHost, ILogger logger)
+ {
+ _appHost = appHost;
+ _logger = logger;
+ _syncManager = (SyncManager)syncManager;
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(IHasMediaSources item, CancellationToken cancellationToken)
+ {
+ var jobItemResult = _syncManager.GetJobItems(new SyncJobItemQuery
+ {
+ AddMetadata = false,
+ Statuses = new[] { SyncJobItemStatus.Synced },
+ ItemId = item.Id.ToString("N")
+ });
+
+ var list = new List<MediaSourceInfo>();
+
+ if (jobItemResult.Items.Length > 0)
+ {
+ var targets = _syncManager.ServerSyncProviders
+ .SelectMany(i => i.GetAllSyncTargets().Select(t => new Tuple<IServerSyncProvider, SyncTarget>(i, t)))
+ .ToList();
+
+ var serverId = _appHost.SystemId;
+
+ foreach (var jobItem in jobItemResult.Items)
+ {
+ var targetTuple = targets.FirstOrDefault(i => string.Equals(i.Item2.Id, jobItem.TargetId, StringComparison.OrdinalIgnoreCase));
+
+ if (targetTuple != null)
+ {
+ var syncTarget = targetTuple.Item2;
+ var syncProvider = targetTuple.Item1;
+ var dataProvider = _syncManager.GetDataProvider(targetTuple.Item1, syncTarget);
+
+ var localItems = await dataProvider.GetItems(syncTarget, serverId, item.Id.ToString("N")).ConfigureAwait(false);
+
+ foreach (var localItem in localItems)
+ {
+ foreach (var mediaSource in localItem.Item.MediaSources)
+ {
+ AddMediaSource(list, localItem, mediaSource, syncProvider, syncTarget);
+ }
+ }
+ }
+ }
+ }
+
+ return list;
+ }
+
+ private void AddMediaSource(List<MediaSourceInfo> list,
+ LocalItem item,
+ MediaSourceInfo mediaSource,
+ IServerSyncProvider provider,
+ SyncTarget target)
+ {
+ SetStaticMediaSourceInfo(item, mediaSource);
+
+ var requiresDynamicAccess = provider as IHasDynamicAccess;
+
+ if (requiresDynamicAccess != null)
+ {
+ mediaSource.RequiresOpening = true;
+
+ var keyList = new List<string>();
+ keyList.Add(provider.GetType().FullName.GetMD5().ToString("N"));
+ keyList.Add(target.Id.GetMD5().ToString("N"));
+ keyList.Add(item.Id);
+ mediaSource.OpenToken = string.Join(StreamIdDelimeterString, keyList.ToArray());
+ }
+
+ list.Add(mediaSource);
+ }
+
+ // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
+ private const string StreamIdDelimeterString = "_";
+
+ public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> OpenMediaSource(string openToken, CancellationToken cancellationToken)
+ {
+ var openKeys = openToken.Split(new[] { StreamIdDelimeterString[0] }, 3);
+
+ var provider = _syncManager.ServerSyncProviders
+ .FirstOrDefault(i => string.Equals(openKeys[0], i.GetType().FullName.GetMD5().ToString("N"), StringComparison.OrdinalIgnoreCase));
+
+ var target = provider.GetAllSyncTargets()
+ .FirstOrDefault(i => string.Equals(openKeys[1], i.Id.GetMD5().ToString("N"), StringComparison.OrdinalIgnoreCase));
+
+ var dataProvider = _syncManager.GetDataProvider(provider, target);
+ var localItem = await dataProvider.Get(target, openKeys[2]).ConfigureAwait(false);
+
+ var fileId = localItem.FileId;
+ if (string.IsNullOrWhiteSpace(fileId))
+ {
+ }
+
+ var requiresDynamicAccess = (IHasDynamicAccess)provider;
+ var dynamicInfo = await requiresDynamicAccess.GetSyncedFileInfo(fileId, target, cancellationToken).ConfigureAwait(false);
+
+ var mediaSource = localItem.Item.MediaSources.First();
+ mediaSource.LiveStreamId = Guid.NewGuid().ToString();
+ SetStaticMediaSourceInfo(localItem, mediaSource);
+
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (!string.IsNullOrWhiteSpace(stream.ExternalId))
+ {
+ var dynamicStreamInfo = await requiresDynamicAccess.GetSyncedFileInfo(stream.ExternalId, target, cancellationToken).ConfigureAwait(false);
+ stream.Path = dynamicStreamInfo.Path;
+ }
+ }
+
+ mediaSource.Path = dynamicInfo.Path;
+ mediaSource.Protocol = dynamicInfo.Protocol;
+ mediaSource.RequiredHttpHeaders = dynamicInfo.RequiredHttpHeaders;
+
+ return new Tuple<MediaSourceInfo, IDirectStreamProvider>(mediaSource, null);
+ }
+
+ private void SetStaticMediaSourceInfo(LocalItem item, MediaSourceInfo mediaSource)
+ {
+ mediaSource.Id = item.Id;
+ mediaSource.SupportsTranscoding = false;
+ if (mediaSource.Protocol == MediaBrowser.Model.MediaInfo.MediaProtocol.File)
+ {
+ mediaSource.ETag = item.Id;
+ }
+ }
+
+ public Task CloseMediaSource(string liveStreamId)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Sync/TargetDataProvider.cs b/Emby.Server.Implementations/Sync/TargetDataProvider.cs
new file mode 100644
index 000000000..fbd82aa7a
--- /dev/null
+++ b/Emby.Server.Implementations/Sync/TargetDataProvider.cs
@@ -0,0 +1,188 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Sync;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Sync;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Sync
+{
+ public class TargetDataProvider : ISyncDataProvider
+ {
+ private readonly SyncTarget _target;
+ private readonly IServerSyncProvider _provider;
+
+ private readonly SemaphoreSlim _dataLock = new SemaphoreSlim(1, 1);
+ private List<LocalItem> _items;
+
+ private readonly ILogger _logger;
+ private readonly IJsonSerializer _json;
+ private readonly IFileSystem _fileSystem;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IMemoryStreamFactory _memoryStreamProvider;
+
+ public TargetDataProvider(IServerSyncProvider provider, SyncTarget target, IServerApplicationHost appHost, ILogger logger, IJsonSerializer json, IFileSystem fileSystem, IApplicationPaths appPaths, IMemoryStreamFactory memoryStreamProvider)
+ {
+ _logger = logger;
+ _json = json;
+ _provider = provider;
+ _target = target;
+ _fileSystem = fileSystem;
+ _appPaths = appPaths;
+ _memoryStreamProvider = memoryStreamProvider;
+ _appHost = appHost;
+ }
+
+ private string[] GetRemotePath()
+ {
+ var parts = new List<string>
+ {
+ _appHost.FriendlyName,
+ "data.json"
+ };
+
+ parts = parts.Select(i => GetValidFilename(_provider, i)).ToList();
+
+ return parts.ToArray();
+ }
+
+ private string GetValidFilename(IServerSyncProvider provider, string filename)
+ {
+ // We can always add this method to the sync provider if it's really needed
+ return _fileSystem.GetValidFilename(filename);
+ }
+
+ private async Task<List<LocalItem>> RetrieveItems(CancellationToken cancellationToken)
+ {
+ _logger.Debug("Getting {0} from {1}", string.Join(MediaSync.PathSeparatorString, GetRemotePath().ToArray()), _provider.Name);
+
+ var fileResult = await _provider.GetFiles(GetRemotePath().ToArray(), _target, cancellationToken).ConfigureAwait(false);
+
+ if (fileResult.Items.Length > 0)
+ {
+ using (var stream = await _provider.GetFile(fileResult.Items[0].FullName, _target, new Progress<double>(), cancellationToken))
+ {
+ return _json.DeserializeFromStream<List<LocalItem>>(stream);
+ }
+ }
+
+ return new List<LocalItem>();
+ }
+
+ private async Task EnsureData(CancellationToken cancellationToken)
+ {
+ if (_items == null)
+ {
+ _items = await RetrieveItems(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task SaveData(List<LocalItem> items, CancellationToken cancellationToken)
+ {
+ using (var stream = _memoryStreamProvider.CreateNew())
+ {
+ _json.SerializeToStream(items, stream);
+
+ // Save to sync provider
+ stream.Position = 0;
+ var remotePath = GetRemotePath();
+ _logger.Debug("Saving data.json to {0}. Remote path: {1}", _provider.Name, string.Join("/", remotePath));
+
+ await _provider.SendFile(stream, remotePath, _target, new Progress<double>(), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task<T> GetData<T>(bool enableCache, Func<List<LocalItem>, T> dataFactory)
+ {
+ if (!enableCache)
+ {
+ var items = await RetrieveItems(CancellationToken.None).ConfigureAwait(false);
+ var newCache = items.ToList();
+ var result = dataFactory(items);
+ await UpdateCache(newCache).ConfigureAwait(false);
+ return result;
+ }
+
+ await _dataLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ await EnsureData(CancellationToken.None).ConfigureAwait(false);
+
+ return dataFactory(_items);
+ }
+ finally
+ {
+ _dataLock.Release();
+ }
+ }
+
+ private async Task UpdateData(Func<List<LocalItem>, List<LocalItem>> action)
+ {
+ var items = await RetrieveItems(CancellationToken.None).ConfigureAwait(false);
+ items = action(items);
+ await SaveData(items.ToList(), CancellationToken.None).ConfigureAwait(false);
+
+ await UpdateCache(null).ConfigureAwait(false);
+ }
+
+ private async Task UpdateCache(List<LocalItem> list)
+ {
+ await _dataLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ _items = list;
+ }
+ finally
+ {
+ _dataLock.Release();
+ }
+ }
+
+ public Task<List<LocalItem>> GetLocalItems(SyncTarget target, string serverId)
+ {
+ return GetData(false, items => items.Where(i => string.Equals(i.ServerId, serverId, StringComparison.OrdinalIgnoreCase)).ToList());
+ }
+
+ public Task AddOrUpdate(SyncTarget target, LocalItem item)
+ {
+ return UpdateData(items =>
+ {
+ var list = items.Where(i => !string.Equals(i.Id, item.Id, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ list.Add(item);
+
+ return list;
+ });
+ }
+
+ public Task Delete(SyncTarget target, string id)
+ {
+ return UpdateData(items => items.Where(i => !string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)).ToList());
+ }
+
+ public Task<LocalItem> Get(SyncTarget target, string id)
+ {
+ return GetData(true, items => items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ public Task<List<LocalItem>> GetItems(SyncTarget target, string serverId, string itemId)
+ {
+ return GetData(true, items => items.Where(i => string.Equals(i.ServerId, serverId, StringComparison.OrdinalIgnoreCase) && string.Equals(i.ItemId, itemId, StringComparison.OrdinalIgnoreCase)).ToList());
+ }
+
+ public Task<List<LocalItem>> GetItemsBySyncJobItemId(SyncTarget target, string serverId, string syncJobItemId)
+ {
+ return GetData(false, items => items.Where(i => string.Equals(i.ServerId, serverId, StringComparison.OrdinalIgnoreCase) && string.Equals(i.SyncJobItemId, syncJobItemId, StringComparison.OrdinalIgnoreCase)).ToList());
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/TV/SeriesPostScanTask.cs b/Emby.Server.Implementations/TV/SeriesPostScanTask.cs
new file mode 100644
index 000000000..3d93561f1
--- /dev/null
+++ b/Emby.Server.Implementations/TV/SeriesPostScanTask.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Tasks;
+using MediaBrowser.Model.Threading;
+using MediaBrowser.Model.Xml;
+using MediaBrowser.Providers.TV;
+
+namespace Emby.Server.Implementations.TV
+{
+ class SeriesGroup : List<Series>, IGrouping<string, Series>
+ {
+ public string Key { get; set; }
+ }
+
+ class SeriesPostScanTask : ILibraryPostScanTask, IHasOrder
+ {
+ /// <summary>
+ /// The _library manager
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILogger _logger;
+ private readonly ILocalizationManager _localization;
+ private readonly IFileSystem _fileSystem;
+ private readonly IXmlReaderSettingsFactory _xmlSettings;
+
+ public SeriesPostScanTask(ILibraryManager libraryManager, ILogger logger, IServerConfigurationManager config, ILocalizationManager localization, IFileSystem fileSystem, IXmlReaderSettingsFactory xmlSettings)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _config = config;
+ _localization = localization;
+ _fileSystem = fileSystem;
+ _xmlSettings = xmlSettings;
+ }
+
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return RunInternal(progress, cancellationToken);
+ }
+
+ private Task RunInternal(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var seriesList = _libraryManager.GetItemList(new InternalItemsQuery()
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ Recursive = true,
+ GroupByPresentationUniqueKey = false
+
+ }).Cast<Series>().ToList();
+
+ var seriesGroups = FindSeriesGroups(seriesList).Where(g => !string.IsNullOrEmpty(g.Key)).ToList();
+
+ return new MissingEpisodeProvider(_logger, _config, _libraryManager, _localization, _fileSystem, _xmlSettings).Run(seriesGroups, true, cancellationToken);
+ }
+
+ internal static IEnumerable<IGrouping<string, Series>> FindSeriesGroups(List<Series> seriesList)
+ {
+ var links = seriesList.ToDictionary(s => s, s => seriesList.Where(c => c != s && ShareProviderId(s, c)).ToList());
+
+ var visited = new HashSet<Series>();
+
+ foreach (var series in seriesList)
+ {
+ if (!visited.Contains(series))
+ {
+ var group = new SeriesGroup();
+ FindAllLinked(series, visited, links, group);
+
+ group.Key = group.Select(s => s.PresentationUniqueKey).FirstOrDefault(id => !string.IsNullOrEmpty(id));
+
+ yield return group;
+ }
+ }
+ }
+
+ private static void FindAllLinked(Series series, HashSet<Series> visited, IDictionary<Series, List<Series>> linksMap, List<Series> results)
+ {
+ results.Add(series);
+ visited.Add(series);
+
+ var links = linksMap[series];
+
+ foreach (var s in links)
+ {
+ if (!visited.Contains(s))
+ {
+ FindAllLinked(s, visited, linksMap, results);
+ }
+ }
+ }
+
+ private static bool ShareProviderId(Series a, Series b)
+ {
+ return string.Equals(a.PresentationUniqueKey, b.PresentationUniqueKey, StringComparison.Ordinal);
+ }
+
+ public int Order
+ {
+ get
+ {
+ // Run after tvdb update task
+ return 1;
+ }
+ }
+ }
+
+ public class CleanMissingEpisodesEntryPoint : IServerEntryPoint
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+ private readonly ILogger _logger;
+ private readonly ILocalizationManager _localization;
+ private readonly IFileSystem _fileSystem;
+ private readonly object _libraryChangedSyncLock = new object();
+ private const int LibraryUpdateDuration = 180000;
+ private readonly ITaskManager _taskManager;
+ private readonly IXmlReaderSettingsFactory _xmlSettings;
+ private readonly ITimerFactory _timerFactory;
+
+ public CleanMissingEpisodesEntryPoint(ILibraryManager libraryManager, IServerConfigurationManager config, ILogger logger, ILocalizationManager localization, IFileSystem fileSystem, ITaskManager taskManager, IXmlReaderSettingsFactory xmlSettings, ITimerFactory timerFactory)
+ {
+ _libraryManager = libraryManager;
+ _config = config;
+ _logger = logger;
+ _localization = localization;
+ _fileSystem = fileSystem;
+ _taskManager = taskManager;
+ _xmlSettings = xmlSettings;
+ _timerFactory = timerFactory;
+ }
+
+ private ITimer LibraryUpdateTimer { get; set; }
+
+ public void Run()
+ {
+ _libraryManager.ItemAdded += _libraryManager_ItemAdded;
+ }
+
+ private void _libraryManager_ItemAdded(object sender, ItemChangeEventArgs e)
+ {
+ if (!FilterItem(e.Item))
+ {
+ return;
+ }
+
+ lock (_libraryChangedSyncLock)
+ {
+ if (LibraryUpdateTimer == null)
+ {
+ LibraryUpdateTimer = _timerFactory.Create(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite);
+ }
+ else
+ {
+ LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite);
+ }
+ }
+ }
+
+ private async void LibraryUpdateTimerCallback(object state)
+ {
+ try
+ {
+ if (MissingEpisodeProvider.IsRunning)
+ {
+ return;
+ }
+
+ if (_libraryManager.IsScanRunning)
+ {
+ return;
+ }
+
+ var seriesList = _libraryManager.GetItemList(new InternalItemsQuery()
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ Recursive = true,
+ GroupByPresentationUniqueKey = false
+
+ }).Cast<Series>().ToList();
+
+ var seriesGroups = SeriesPostScanTask.FindSeriesGroups(seriesList).Where(g => !string.IsNullOrEmpty(g.Key)).ToList();
+
+ await new MissingEpisodeProvider(_logger, _config, _libraryManager, _localization, _fileSystem, _xmlSettings)
+ .Run(seriesGroups, false, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in SeriesPostScanTask", ex);
+ }
+ }
+
+ private bool FilterItem(BaseItem item)
+ {
+ return item is Episode && item.LocationType != LocationType.Virtual;
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ if (LibraryUpdateTimer != null)
+ {
+ LibraryUpdateTimer.Dispose();
+ LibraryUpdateTimer = null;
+ }
+
+ _libraryManager.ItemAdded -= _libraryManager_ItemAdded;
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
new file mode 100644
index 000000000..6bf412525
--- /dev/null
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -0,0 +1,249 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Configuration;
+
+namespace Emby.Server.Implementations.TV
+{
+ public class TVSeriesManager : ITVSeriesManager
+ {
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IServerConfigurationManager _config;
+
+ public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager config)
+ {
+ _userManager = userManager;
+ _userDataManager = userDataManager;
+ _libraryManager = libraryManager;
+ _config = config;
+ }
+
+ public QueryResult<BaseItem> GetNextUp(NextUpQuery request)
+ {
+ var user = _userManager.GetUserById(request.UserId);
+
+ if (user == null)
+ {
+ throw new ArgumentException("User not found");
+ }
+
+ var parentIdGuid = string.IsNullOrWhiteSpace(request.ParentId) ? (Guid?)null : new Guid(request.ParentId);
+
+ string presentationUniqueKey = null;
+ int? limit = null;
+ if (!string.IsNullOrWhiteSpace(request.SeriesId))
+ {
+ var series = _libraryManager.GetItemById(request.SeriesId);
+
+ if (series != null)
+ {
+ presentationUniqueKey = GetUniqueSeriesKey(series);
+ limit = 1;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(presentationUniqueKey) && limit.HasValue)
+ {
+ limit = limit.Value + 10;
+ }
+
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ SortBy = new[] { ItemSortBy.SeriesDatePlayed },
+ SortOrder = SortOrder.Descending,
+ PresentationUniqueKey = presentationUniqueKey,
+ Limit = limit,
+ ParentId = parentIdGuid,
+ Recursive = true,
+ DtoOptions = new MediaBrowser.Controller.Dto.DtoOptions
+ {
+ Fields = new List<ItemFields>
+ {
+
+ }
+ }
+
+ }).Cast<Series>().Select(GetUniqueSeriesKey);
+
+ // Avoid implicitly captured closure
+ var episodes = GetNextUpEpisodes(request, user, items);
+
+ return GetResult(episodes, null, request);
+ }
+
+ public QueryResult<BaseItem> GetNextUp(NextUpQuery request, List<Folder> parentsFolders)
+ {
+ var user = _userManager.GetUserById(request.UserId);
+
+ if (user == null)
+ {
+ throw new ArgumentException("User not found");
+ }
+
+ string presentationUniqueKey = null;
+ int? limit = null;
+ if (!string.IsNullOrWhiteSpace(request.SeriesId))
+ {
+ var series = _libraryManager.GetItemById(request.SeriesId);
+
+ if (series != null)
+ {
+ presentationUniqueKey = GetUniqueSeriesKey(series);
+ limit = 1;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(presentationUniqueKey) && limit.HasValue)
+ {
+ limit = limit.Value + 10;
+ }
+
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { typeof(Series).Name },
+ SortBy = new[] { ItemSortBy.SeriesDatePlayed },
+ SortOrder = SortOrder.Descending,
+ PresentationUniqueKey = presentationUniqueKey,
+ Limit = limit,
+ DtoOptions = new MediaBrowser.Controller.Dto.DtoOptions
+ {
+ Fields = new List<ItemFields>
+ {
+
+ },
+ EnableImages = false
+ }
+
+ }, parentsFolders.Cast<BaseItem>().ToList()).Cast<Series>().Select(GetUniqueSeriesKey);
+
+ // Avoid implicitly captured closure
+ var episodes = GetNextUpEpisodes(request, user, items);
+
+ return GetResult(episodes, null, request);
+ }
+
+ public IEnumerable<Episode> GetNextUpEpisodes(NextUpQuery request, User user, IEnumerable<string> seriesKeys)
+ {
+ // Avoid implicitly captured closure
+ var currentUser = user;
+
+ var allNextUp = seriesKeys
+ .Select(i => GetNextUp(i, currentUser));
+
+ //allNextUp = allNextUp.OrderByDescending(i => i.Item1);
+
+ // If viewing all next up for all series, remove first episodes
+ // But if that returns empty, keep those first episodes (avoid completely empty view)
+ var alwaysEnableFirstEpisode = !string.IsNullOrWhiteSpace(request.SeriesId);
+
+ return allNextUp
+ .Where(i =>
+ {
+ if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue)
+ {
+ return true;
+ }
+
+ return false;
+ })
+ .Select(i => i.Item2())
+ .Where(i => i != null)
+ .Take(request.Limit ?? int.MaxValue);
+ }
+
+ private string GetUniqueSeriesKey(BaseItem series)
+ {
+ return series.GetPresentationUniqueKey();
+ }
+
+ /// <summary>
+ /// Gets the next up.
+ /// </summary>
+ /// <returns>Task{Episode}.</returns>
+ private Tuple<DateTime, Func<Episode>> GetNextUp(string seriesKey, User user)
+ {
+ var enableSeriesPresentationKey = _config.Configuration.EnableSeriesPresentationUniqueKey;
+
+ var lastWatchedEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ AncestorWithPresentationUniqueKey = enableSeriesPresentationKey ? null : seriesKey,
+ SeriesPresentationUniqueKey = enableSeriesPresentationKey ? seriesKey : null,
+ IncludeItemTypes = new[] { typeof(Episode).Name },
+ SortBy = new[] { ItemSortBy.SortName },
+ SortOrder = SortOrder.Descending,
+ IsPlayed = true,
+ Limit = 1,
+ ParentIndexNumberNotEquals = 0,
+ DtoOptions = new MediaBrowser.Controller.Dto.DtoOptions
+ {
+ Fields = new List<ItemFields>
+ {
+
+ },
+ EnableImages = false
+ }
+
+ }).FirstOrDefault();
+
+ Func<Episode> getEpisode = () =>
+ {
+ return _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ AncestorWithPresentationUniqueKey = enableSeriesPresentationKey ? null : seriesKey,
+ SeriesPresentationUniqueKey = enableSeriesPresentationKey ? seriesKey : null,
+ IncludeItemTypes = new[] { typeof(Episode).Name },
+ SortBy = new[] { ItemSortBy.SortName },
+ SortOrder = SortOrder.Ascending,
+ Limit = 1,
+ IsPlayed = false,
+ IsVirtualItem = false,
+ ParentIndexNumberNotEquals = 0,
+ MinSortName = lastWatchedEpisode == null ? null : lastWatchedEpisode.SortName
+
+ }).Cast<Episode>().FirstOrDefault();
+ };
+
+ if (lastWatchedEpisode != null)
+ {
+ var userData = _userDataManager.GetUserData(user, lastWatchedEpisode);
+
+ var lastWatchedDate = userData.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
+
+ return new Tuple<DateTime, Func<Episode>>(lastWatchedDate, getEpisode);
+ }
+
+ // Return the first episode
+ return new Tuple<DateTime, Func<Episode>>(DateTime.MinValue, getEpisode);
+ }
+
+ private QueryResult<BaseItem> GetResult(IEnumerable<BaseItem> items, int? totalRecordLimit, NextUpQuery query)
+ {
+ var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray();
+ var totalCount = itemsArray.Length;
+
+ if (query.Limit.HasValue)
+ {
+ itemsArray = itemsArray.Skip(query.StartIndex ?? 0).Take(query.Limit.Value).ToArray();
+ }
+ else if (query.StartIndex.HasValue)
+ {
+ itemsArray = itemsArray.Skip(query.StartIndex.Value).ToArray();
+ }
+
+ return new QueryResult<BaseItem>
+ {
+ TotalRecordCount = totalCount,
+ Items = itemsArray
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs
new file mode 100644
index 000000000..c15e0ee41
--- /dev/null
+++ b/Emby.Server.Implementations/Udp/UdpServer.cs
@@ -0,0 +1,247 @@
+using MediaBrowser.Controller;
+using MediaBrowser.Model.ApiClient;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Net;
+
+namespace Emby.Server.Implementations.Udp
+{
+ /// <summary>
+ /// Provides a Udp Server
+ /// </summary>
+ public class UdpServer : IDisposable
+ {
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private bool _isDisposed;
+
+ private readonly List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>> _responders = new List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>>();
+
+ private readonly IServerApplicationHost _appHost;
+ private readonly IJsonSerializer _json;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UdpServer" /> class.
+ /// </summary>
+ public UdpServer(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory)
+ {
+ _logger = logger;
+ _appHost = appHost;
+ _json = json;
+ _socketFactory = socketFactory;
+
+ AddMessageResponder("who is EmbyServer?", true, RespondToV2Message);
+ AddMessageResponder("who is MediaBrowserServer_v2?", false, RespondToV2Message);
+ }
+
+ private void AddMessageResponder(string message, bool isSubstring, Func<string, IpEndPointInfo, Encoding, Task> responder)
+ {
+ _responders.Add(new Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>(message, isSubstring, responder));
+ }
+
+ /// <summary>
+ /// Raises the <see cref="E:MessageReceived" /> event.
+ /// </summary>
+ private async void OnMessageReceived(GenericEventArgs<SocketReceiveResult> e)
+ {
+ var message = e.Argument;
+
+ var encoding = Encoding.UTF8;
+ var responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding);
+
+ if (responder == null)
+ {
+ encoding = Encoding.Unicode;
+ responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding);
+ }
+
+ if (responder != null)
+ {
+ try
+ {
+ await responder.Item2.Item3(responder.Item1, message.RemoteEndPoint, encoding).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error in OnMessageReceived", ex);
+ }
+ }
+ }
+
+ private Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>> GetResponder(byte[] buffer, int bytesReceived, Encoding encoding)
+ {
+ var text = encoding.GetString(buffer, 0, bytesReceived);
+ var responder = _responders.FirstOrDefault(i =>
+ {
+ if (i.Item2)
+ {
+ return text.IndexOf(i.Item1, StringComparison.OrdinalIgnoreCase) != -1;
+ }
+ return string.Equals(i.Item1, text, StringComparison.OrdinalIgnoreCase);
+ });
+
+ if (responder == null)
+ {
+ return null;
+ }
+ return new Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, Task>>>(text, responder);
+ }
+
+ private async Task RespondToV2Message(string messageText, IpEndPointInfo endpoint, Encoding encoding)
+ {
+ var parts = messageText.Split('|');
+
+ var localUrl = await _appHost.GetLocalApiUrl().ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(localUrl))
+ {
+ var response = new ServerDiscoveryInfo
+ {
+ Address = localUrl,
+ Id = _appHost.SystemId,
+ Name = _appHost.FriendlyName
+ };
+
+ await SendAsync(encoding.GetBytes(_json.SerializeToString(response)), endpoint).ConfigureAwait(false);
+
+ if (parts.Length > 1)
+ {
+ _appHost.EnableLoopback(parts[1]);
+ }
+ }
+ else
+ {
+ _logger.Warn("Unable to respond to udp request because the local ip address could not be determined.");
+ }
+ }
+
+ /// <summary>
+ /// The _udp client
+ /// </summary>
+ private IUdpSocket _udpClient;
+ private readonly ISocketFactory _socketFactory;
+
+ /// <summary>
+ /// Starts the specified port.
+ /// </summary>
+ /// <param name="port">The port.</param>
+ public void Start(int port)
+ {
+ _udpClient = _socketFactory.CreateUdpSocket(port);
+
+ Task.Run(() => StartListening());
+ }
+
+ private async void StartListening()
+ {
+ while (!_isDisposed)
+ {
+ try
+ {
+ var result = await _udpClient.ReceiveAsync().ConfigureAwait(false);
+
+ OnMessageReceived(result);
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error receiving udp message", ex);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Called when [message received].
+ /// </summary>
+ /// <param name="message">The message.</param>
+ private void OnMessageReceived(SocketReceiveResult message)
+ {
+ if (message.RemoteEndPoint.Port == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ OnMessageReceived(new GenericEventArgs<SocketReceiveResult>
+ {
+ Argument = message
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error handling UDP message", ex);
+ }
+ }
+
+ /// <summary>
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ /// </summary>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Stops this instance.
+ /// </summary>
+ public void Stop()
+ {
+ _isDisposed = true;
+
+ if (_udpClient != null)
+ {
+ _udpClient.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ Stop();
+ }
+ }
+
+ public async Task SendAsync(byte[] bytes, IpEndPointInfo remoteEndPoint)
+ {
+ if (bytes == null)
+ {
+ throw new ArgumentNullException("bytes");
+ }
+
+ if (remoteEndPoint == null)
+ {
+ throw new ArgumentNullException("remoteEndPoint");
+ }
+
+ try
+ {
+ await _udpClient.SendAsync(bytes, bytes.Length, remoteEndPoint).ConfigureAwait(false);
+
+ _logger.Info("Udp message sent to {0}", remoteEndPoint);
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error sending message to {0}", ex, remoteEndPoint);
+ }
+ }
+ }
+
+}
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
new file mode 100644
index 000000000..0420900c5
--- /dev/null
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -0,0 +1,694 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Events;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Common.Security;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.Events;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Updates;
+
+namespace Emby.Server.Implementations.Updates
+{
+ /// <summary>
+ /// Manages all install, uninstall and update operations (both plugins and system)
+ /// </summary>
+ public class InstallationManager : IInstallationManager
+ {
+ public event EventHandler<InstallationEventArgs> PackageInstalling;
+ public event EventHandler<InstallationEventArgs> PackageInstallationCompleted;
+ public event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed;
+ public event EventHandler<InstallationEventArgs> PackageInstallationCancelled;
+
+ /// <summary>
+ /// The current installations
+ /// </summary>
+ public List<Tuple<InstallationInfo, CancellationTokenSource>> CurrentInstallations { get; set; }
+
+ /// <summary>
+ /// The completed installations
+ /// </summary>
+ private ConcurrentBag<InstallationInfo> CompletedInstallationsInternal { get; set; }
+
+ public IEnumerable<InstallationInfo> CompletedInstallations
+ {
+ get { return CompletedInstallationsInternal; }
+ }
+
+ #region PluginUninstalled Event
+ /// <summary>
+ /// Occurs when [plugin uninstalled].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<IPlugin>> PluginUninstalled;
+
+ /// <summary>
+ /// Called when [plugin uninstalled].
+ /// </summary>
+ /// <param name="plugin">The plugin.</param>
+ private void OnPluginUninstalled(IPlugin plugin)
+ {
+ EventHelper.FireEventIfNotNull(PluginUninstalled, this, new GenericEventArgs<IPlugin> { Argument = plugin }, _logger);
+ }
+ #endregion
+
+ #region PluginUpdated Event
+ /// <summary>
+ /// Occurs when [plugin updated].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>>> PluginUpdated;
+ /// <summary>
+ /// Called when [plugin updated].
+ /// </summary>
+ /// <param name="plugin">The plugin.</param>
+ /// <param name="newVersion">The new version.</param>
+ private void OnPluginUpdated(IPlugin plugin, PackageVersionInfo newVersion)
+ {
+ _logger.Info("Plugin updated: {0} {1} {2}", newVersion.name, newVersion.versionStr ?? string.Empty, newVersion.classification);
+
+ EventHelper.FireEventIfNotNull(PluginUpdated, this, new GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> { Argument = new Tuple<IPlugin, PackageVersionInfo>(plugin, newVersion) }, _logger);
+
+ _applicationHost.NotifyPendingRestart();
+ }
+ #endregion
+
+ #region PluginInstalled Event
+ /// <summary>
+ /// Occurs when [plugin updated].
+ /// </summary>
+ public event EventHandler<GenericEventArgs<PackageVersionInfo>> PluginInstalled;
+ /// <summary>
+ /// Called when [plugin installed].
+ /// </summary>
+ /// <param name="package">The package.</param>
+ private void OnPluginInstalled(PackageVersionInfo package)
+ {
+ _logger.Info("New plugin installed: {0} {1} {2}", package.name, package.versionStr ?? string.Empty, package.classification);
+
+ EventHelper.FireEventIfNotNull(PluginInstalled, this, new GenericEventArgs<PackageVersionInfo> { Argument = package }, _logger);
+
+ _applicationHost.NotifyPendingRestart();
+ }
+ #endregion
+
+ /// <summary>
+ /// The _logger
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly IApplicationPaths _appPaths;
+ private readonly IHttpClient _httpClient;
+ private readonly IJsonSerializer _jsonSerializer;
+ private readonly ISecurityManager _securityManager;
+ private readonly IConfigurationManager _config;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Gets the application host.
+ /// </summary>
+ /// <value>The application host.</value>
+ private readonly IApplicationHost _applicationHost;
+
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ public InstallationManager(ILogger logger, IApplicationHost appHost, IApplicationPaths appPaths, IHttpClient httpClient, IJsonSerializer jsonSerializer, ISecurityManager securityManager, IConfigurationManager config, IFileSystem fileSystem, ICryptoProvider cryptographyProvider)
+ {
+ if (logger == null)
+ {
+ throw new ArgumentNullException("logger");
+ }
+
+ CurrentInstallations = new List<Tuple<InstallationInfo, CancellationTokenSource>>();
+ CompletedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
+
+ _applicationHost = appHost;
+ _appPaths = appPaths;
+ _httpClient = httpClient;
+ _jsonSerializer = jsonSerializer;
+ _securityManager = securityManager;
+ _config = config;
+ _fileSystem = fileSystem;
+ _cryptographyProvider = cryptographyProvider;
+ _logger = logger;
+ }
+
+ private Version GetPackageVersion(PackageVersionInfo version)
+ {
+ return new Version(ValueOrDefault(version.versionStr, "0.0.0.1"));
+ }
+
+ private static string ValueOrDefault(string str, string def)
+ {
+ return string.IsNullOrEmpty(str) ? def : str;
+ }
+
+ /// <summary>
+ /// Gets all available packages.
+ /// </summary>
+ /// <returns>Task{List{PackageInfo}}.</returns>
+ public async Task<IEnumerable<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken,
+ bool withRegistration = true,
+ string packageType = null,
+ Version applicationVersion = null)
+ {
+ var data = new Dictionary<string, string>
+ {
+ { "key", _securityManager.SupporterKey },
+ { "mac", _applicationHost.SystemId },
+ { "systemid", _applicationHost.SystemId }
+ };
+
+ if (withRegistration)
+ {
+ using (var json = await _httpClient.Post("https://www.mb3admin.com/admin/service/package/retrieveall", data, cancellationToken).ConfigureAwait(false))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var packages = _jsonSerializer.DeserializeFromStream<List<PackageInfo>>(json).ToList();
+
+ return FilterPackages(packages, packageType, applicationVersion);
+ }
+ }
+ else
+ {
+ var packages = await GetAvailablePackagesWithoutRegistrationInfo(cancellationToken).ConfigureAwait(false);
+
+ return FilterPackages(packages.ToList(), packageType, applicationVersion);
+ }
+ }
+
+ private DateTime _lastPackageUpdateTime;
+
+ /// <summary>
+ /// Gets all available packages.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task{List{PackageInfo}}.</returns>
+ public async Task<IEnumerable<PackageInfo>> GetAvailablePackagesWithoutRegistrationInfo(CancellationToken cancellationToken)
+ {
+ _logger.Info("Opening {0}", PackageCachePath);
+ try
+ {
+ using (var stream = _fileSystem.OpenRead(PackageCachePath))
+ {
+ var packages = _jsonSerializer.DeserializeFromStream<List<PackageInfo>>(stream).ToList();
+
+ if (DateTime.UtcNow - _lastPackageUpdateTime > GetCacheLength())
+ {
+ UpdateCachedPackages(CancellationToken.None, false);
+ }
+
+ return packages;
+ }
+ }
+ catch (Exception)
+ {
+
+ }
+
+ _lastPackageUpdateTime = DateTime.MinValue;
+ await UpdateCachedPackages(cancellationToken, true).ConfigureAwait(false);
+ using (var stream = _fileSystem.OpenRead(PackageCachePath))
+ {
+ return _jsonSerializer.DeserializeFromStream<List<PackageInfo>>(stream).ToList();
+ }
+ }
+
+ private string PackageCachePath
+ {
+ get { return Path.Combine(_appPaths.CachePath, "serverpackages.json"); }
+ }
+
+ private readonly SemaphoreSlim _updateSemaphore = new SemaphoreSlim(1, 1);
+ private async Task UpdateCachedPackages(CancellationToken cancellationToken, bool throwErrors)
+ {
+ await _updateSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ if (DateTime.UtcNow - _lastPackageUpdateTime < GetCacheLength())
+ {
+ return;
+ }
+
+ var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions
+ {
+ Url = "https://www.mb3admin.com/admin/service/MB3Packages.json",
+ CancellationToken = cancellationToken,
+ Progress = new Progress<Double>()
+
+ }).ConfigureAwait(false);
+
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(PackageCachePath));
+
+ _fileSystem.CopyFile(tempFile, PackageCachePath, true);
+ _lastPackageUpdateTime = DateTime.UtcNow;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Error updating package cache", ex);
+
+ if (throwErrors)
+ {
+ throw;
+ }
+ }
+ finally
+ {
+ _updateSemaphore.Release();
+ }
+ }
+
+ private TimeSpan GetCacheLength()
+ {
+ switch (_config.CommonConfiguration.SystemUpdateLevel)
+ {
+ case PackageVersionClass.Beta:
+ return TimeSpan.FromMinutes(30);
+ case PackageVersionClass.Dev:
+ return TimeSpan.FromMinutes(3);
+ default:
+ return TimeSpan.FromHours(24);
+ }
+ }
+
+ protected IEnumerable<PackageInfo> FilterPackages(List<PackageInfo> packages)
+ {
+ foreach (var package in packages)
+ {
+ package.versions = package.versions.Where(v => !string.IsNullOrWhiteSpace(v.sourceUrl))
+ .OrderByDescending(GetPackageVersion).ToList();
+ }
+
+ // Remove packages with no versions
+ packages = packages.Where(p => p.versions.Any()).ToList();
+
+ return packages;
+ }
+
+ protected IEnumerable<PackageInfo> FilterPackages(List<PackageInfo> packages, string packageType, Version applicationVersion)
+ {
+ foreach (var package in packages)
+ {
+ package.versions = package.versions.Where(v => !string.IsNullOrWhiteSpace(v.sourceUrl))
+ .OrderByDescending(GetPackageVersion).ToList();
+ }
+
+ if (!string.IsNullOrWhiteSpace(packageType))
+ {
+ packages = packages.Where(p => string.Equals(p.type, packageType, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ // If an app version was supplied, filter the versions for each package to only include supported versions
+ if (applicationVersion != null)
+ {
+ foreach (var package in packages)
+ {
+ package.versions = package.versions.Where(v => IsPackageVersionUpToDate(v, applicationVersion)).ToList();
+ }
+ }
+
+ // Remove packages with no versions
+ packages = packages.Where(p => p.versions.Any()).ToList();
+
+ return packages;
+ }
+
+ /// <summary>
+ /// Determines whether [is package version up to date] [the specified package version info].
+ /// </summary>
+ /// <param name="packageVersionInfo">The package version info.</param>
+ /// <param name="currentServerVersion">The current server version.</param>
+ /// <returns><c>true</c> if [is package version up to date] [the specified package version info]; otherwise, <c>false</c>.</returns>
+ private bool IsPackageVersionUpToDate(PackageVersionInfo packageVersionInfo, Version currentServerVersion)
+ {
+ if (string.IsNullOrEmpty(packageVersionInfo.requiredVersionStr))
+ {
+ return true;
+ }
+
+ Version requiredVersion;
+
+ return Version.TryParse(packageVersionInfo.requiredVersionStr, out requiredVersion) && currentServerVersion >= requiredVersion;
+ }
+
+ /// <summary>
+ /// Gets the package.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <param name="guid">The assembly guid</param>
+ /// <param name="classification">The classification.</param>
+ /// <param name="version">The version.</param>
+ /// <returns>Task{PackageVersionInfo}.</returns>
+ public async Task<PackageVersionInfo> GetPackage(string name, string guid, PackageVersionClass classification, Version version)
+ {
+ var packages = await GetAvailablePackages(CancellationToken.None).ConfigureAwait(false);
+
+ var package = packages.FirstOrDefault(p => string.Equals(p.guid, guid ?? "none", StringComparison.OrdinalIgnoreCase))
+ ?? packages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+ if (package == null)
+ {
+ return null;
+ }
+
+ return package.versions.FirstOrDefault(v => GetPackageVersion(v).Equals(version) && v.classification == classification);
+ }
+
+ /// <summary>
+ /// Gets the latest compatible version.
+ /// </summary>
+ /// <param name="name">The name.</param>
+ /// <param name="guid">The assembly guid if this is a plug-in</param>
+ /// <param name="currentServerVersion">The current server version.</param>
+ /// <param name="classification">The classification.</param>
+ /// <returns>Task{PackageVersionInfo}.</returns>
+ public async Task<PackageVersionInfo> GetLatestCompatibleVersion(string name, string guid, Version currentServerVersion, PackageVersionClass classification = PackageVersionClass.Release)
+ {
+ var packages = await GetAvailablePackages(CancellationToken.None).ConfigureAwait(false);
+
+ return GetLatestCompatibleVersion(packages, name, guid, currentServerVersion, classification);
+ }
+
+ /// <summary>
+ /// Gets the latest compatible version.
+ /// </summary>
+ /// <param name="availablePackages">The available packages.</param>
+ /// <param name="name">The name.</param>
+ /// <param name="currentServerVersion">The current server version.</param>
+ /// <param name="classification">The classification.</param>
+ /// <returns>PackageVersionInfo.</returns>
+ public PackageVersionInfo GetLatestCompatibleVersion(IEnumerable<PackageInfo> availablePackages, string name, string guid, Version currentServerVersion, PackageVersionClass classification = PackageVersionClass.Release)
+ {
+ var package = availablePackages.FirstOrDefault(p => string.Equals(p.guid, guid ?? "none", StringComparison.OrdinalIgnoreCase))
+ ?? availablePackages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+ if (package == null)
+ {
+ return null;
+ }
+
+ return package.versions
+ .OrderByDescending(GetPackageVersion)
+ .FirstOrDefault(v => v.classification <= classification && IsPackageVersionUpToDate(v, currentServerVersion));
+ }
+
+ /// <summary>
+ /// Gets the available plugin updates.
+ /// </summary>
+ /// <param name="applicationVersion">The current server version.</param>
+ /// <param name="withAutoUpdateEnabled">if set to <c>true</c> [with auto update enabled].</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task{IEnumerable{PackageVersionInfo}}.</returns>
+ public async Task<IEnumerable<PackageVersionInfo>> GetAvailablePluginUpdates(Version applicationVersion, bool withAutoUpdateEnabled, CancellationToken cancellationToken)
+ {
+ var catalog = await GetAvailablePackagesWithoutRegistrationInfo(cancellationToken).ConfigureAwait(false);
+
+ var plugins = _applicationHost.Plugins.ToList();
+
+ if (withAutoUpdateEnabled)
+ {
+ plugins = plugins
+ .Where(p => _config.CommonConfiguration.EnableAutoUpdate)
+ .ToList();
+ }
+
+ // Figure out what needs to be installed
+ var packages = plugins.Select(p =>
+ {
+ var latestPluginInfo = GetLatestCompatibleVersion(catalog, p.Name, p.Id.ToString(), applicationVersion, _config.CommonConfiguration.SystemUpdateLevel);
+
+ return latestPluginInfo != null && GetPackageVersion(latestPluginInfo) > p.Version ? latestPluginInfo : null;
+
+ }).Where(i => i != null).ToList();
+
+ return packages
+ .Where(p => !string.IsNullOrWhiteSpace(p.sourceUrl) && !CompletedInstallations.Any(i => string.Equals(i.AssemblyGuid, p.guid, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ /// <summary>
+ /// Installs the package.
+ /// </summary>
+ /// <param name="package">The package.</param>
+ /// <param name="isPlugin">if set to <c>true</c> [is plugin].</param>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="System.ArgumentNullException">package</exception>
+ public async Task InstallPackage(PackageVersionInfo package, bool isPlugin, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ if (package == null)
+ {
+ throw new ArgumentNullException("package");
+ }
+
+ if (progress == null)
+ {
+ throw new ArgumentNullException("progress");
+ }
+
+ var installationInfo = new InstallationInfo
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Name = package.name,
+ AssemblyGuid = package.guid,
+ UpdateClass = package.classification,
+ Version = package.versionStr
+ };
+
+ var innerCancellationTokenSource = new CancellationTokenSource();
+
+ var tuple = new Tuple<InstallationInfo, CancellationTokenSource>(installationInfo, innerCancellationTokenSource);
+
+ // Add it to the in-progress list
+ lock (CurrentInstallations)
+ {
+ CurrentInstallations.Add(tuple);
+ }
+
+ var innerProgress = new ActionableProgress<double>();
+
+ // Whenever the progress updates, update the outer progress object and InstallationInfo
+ innerProgress.RegisterAction(percent =>
+ {
+ progress.Report(percent);
+
+ installationInfo.PercentComplete = percent;
+ });
+
+ var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token;
+
+ var installationEventArgs = new InstallationEventArgs
+ {
+ InstallationInfo = installationInfo,
+ PackageVersionInfo = package
+ };
+
+ EventHelper.FireEventIfNotNull(PackageInstalling, this, installationEventArgs, _logger);
+
+ try
+ {
+ await InstallPackageInternal(package, isPlugin, innerProgress, linkedToken).ConfigureAwait(false);
+
+ lock (CurrentInstallations)
+ {
+ CurrentInstallations.Remove(tuple);
+ }
+
+ progress.Report(100);
+
+ CompletedInstallationsInternal.Add(installationInfo);
+
+ EventHelper.FireEventIfNotNull(PackageInstallationCompleted, this, installationEventArgs, _logger);
+ }
+ catch (OperationCanceledException)
+ {
+ lock (CurrentInstallations)
+ {
+ CurrentInstallations.Remove(tuple);
+ }
+
+ _logger.Info("Package installation cancelled: {0} {1}", package.name, package.versionStr);
+
+ EventHelper.FireEventIfNotNull(PackageInstallationCancelled, this, installationEventArgs, _logger);
+
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.ErrorException("Package installation failed", ex);
+
+ lock (CurrentInstallations)
+ {
+ CurrentInstallations.Remove(tuple);
+ }
+
+ EventHelper.FireEventIfNotNull(PackageInstallationFailed, this, new InstallationFailedEventArgs
+ {
+ InstallationInfo = installationInfo,
+ Exception = ex
+
+ }, _logger);
+
+ throw;
+ }
+ finally
+ {
+ // Dispose the progress object and remove the installation from the in-progress list
+ innerProgress.Dispose();
+ tuple.Item2.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Installs the package internal.
+ /// </summary>
+ /// <param name="package">The package.</param>
+ /// <param name="isPlugin">if set to <c>true</c> [is plugin].</param>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task InstallPackageInternal(PackageVersionInfo package, bool isPlugin, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ // Do the install
+ await PerformPackageInstallation(progress, package, cancellationToken).ConfigureAwait(false);
+
+ // Do plugin-specific processing
+ if (isPlugin)
+ {
+ // Set last update time if we were installed before
+ var plugin = _applicationHost.Plugins.FirstOrDefault(p => string.Equals(p.Id.ToString(), package.guid, StringComparison.OrdinalIgnoreCase))
+ ?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.name, StringComparison.OrdinalIgnoreCase));
+
+ if (plugin != null)
+ {
+ OnPluginUpdated(plugin, package);
+ }
+ else
+ {
+ OnPluginInstalled(package);
+ }
+ }
+ }
+
+ private async Task PerformPackageInstallation(IProgress<double> progress, PackageVersionInfo package, CancellationToken cancellationToken)
+ {
+ // Target based on if it is an archive or single assembly
+ // zip archives are assumed to contain directory structures relative to our ProgramDataPath
+ var extension = Path.GetExtension(package.targetFilename);
+ var isArchive = string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".rar", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".7z", StringComparison.OrdinalIgnoreCase);
+ var target = Path.Combine(isArchive ? _appPaths.TempUpdatePath : _appPaths.PluginsPath, package.targetFilename);
+
+ // Download to temporary file so that, if interrupted, it won't destroy the existing installation
+ var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions
+ {
+ Url = package.sourceUrl,
+ CancellationToken = cancellationToken,
+ Progress = progress
+
+ }).ConfigureAwait(false);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Validate with a checksum
+ var packageChecksum = string.IsNullOrWhiteSpace(package.checksum) ? Guid.Empty : new Guid(package.checksum);
+ if (packageChecksum != Guid.Empty) // support for legacy uploads for now
+ {
+ using (var stream = _fileSystem.OpenRead(tempFile))
+ {
+ var check = Guid.Parse(BitConverter.ToString(_cryptographyProvider.ComputeMD5(stream)).Replace("-", String.Empty));
+ if (check != packageChecksum)
+ {
+ throw new Exception(string.Format("Download validation failed for {0}. Probably corrupted during transfer.", package.name));
+ }
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Success - move it to the real target
+ try
+ {
+ _fileSystem.CreateDirectory(Path.GetDirectoryName(target));
+ _fileSystem.CopyFile(tempFile, target, true);
+ //If it is an archive - write out a version file so we know what it is
+ if (isArchive)
+ {
+ _fileSystem.WriteAllText(target + ".ver", package.versionStr);
+ }
+ }
+ catch (IOException e)
+ {
+ _logger.ErrorException("Error attempting to move file from {0} to {1}", e, tempFile, target);
+ throw;
+ }
+
+ try
+ {
+ _fileSystem.DeleteFile(tempFile);
+ }
+ catch (IOException e)
+ {
+ // Don't fail because of this
+ _logger.ErrorException("Error deleting temp file {0]", e, tempFile);
+ }
+ }
+
+ /// <summary>
+ /// Uninstalls a plugin
+ /// </summary>
+ /// <param name="plugin">The plugin.</param>
+ /// <exception cref="System.ArgumentException"></exception>
+ public void UninstallPlugin(IPlugin plugin)
+ {
+ plugin.OnUninstalling();
+
+ // Remove it the quick way for now
+ _applicationHost.RemovePlugin(plugin);
+
+ _logger.Info("Deleting plugin file {0}", plugin.AssemblyFilePath);
+
+ _fileSystem.DeleteFile(plugin.AssemblyFilePath);
+
+ OnPluginUninstalled(plugin);
+
+ _applicationHost.NotifyPendingRestart();
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ lock (CurrentInstallations)
+ {
+ foreach (var tuple in CurrentInstallations)
+ {
+ tuple.Item2.Dispose();
+ }
+
+ CurrentInstallations.Clear();
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs
new file mode 100644
index 000000000..ab6307238
--- /dev/null
+++ b/Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs
@@ -0,0 +1,176 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Images;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Querying;
+
+namespace Emby.Server.Implementations.UserViews
+{
+ public class CollectionFolderImageProvider : BaseDynamicImageProvider<CollectionFolder>
+ {
+ public CollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ }
+
+ public override IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new List<ImageType>
+ {
+ ImageType.Primary
+ };
+ }
+
+ protected override async Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var view = (CollectionFolder)item;
+
+ var recursive = !new[] { CollectionType.Playlists, CollectionType.Channels }.Contains(view.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+ var result = await view.GetItems(new InternalItemsQuery
+ {
+ CollapseBoxSetItems = false,
+ Recursive = recursive,
+ ExcludeItemTypes = new[] { "UserView", "CollectionFolder", "Playlist" }
+
+ }).ConfigureAwait(false);
+
+ var items = result.Items.Select(i =>
+ {
+ var episode = i as Episode;
+ if (episode != null)
+ {
+ var series = episode.Series;
+ if (series != null)
+ {
+ return series;
+ }
+
+ return episode;
+ }
+
+ var season = i as Season;
+ if (season != null)
+ {
+ var series = season.Series;
+ if (series != null)
+ {
+ return series;
+ }
+
+ return season;
+ }
+
+ var audio = i as Audio;
+ if (audio != null)
+ {
+ var album = audio.AlbumEntity;
+ if (album != null && album.HasImage(ImageType.Primary))
+ {
+ return album;
+ }
+ }
+
+ return i;
+
+ }).DistinctBy(i => i.Id);
+
+ return GetFinalItems(items.Where(i => i.HasImage(ImageType.Primary) || i.HasImage(ImageType.Thumb)).ToList(), 8);
+ }
+
+ protected override bool Supports(IHasImages item)
+ {
+ return item is CollectionFolder;
+ }
+
+ protected override async Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ {
+ var outputPath = Path.ChangeExtension(outputPathWithoutExtension, ".png");
+
+ if (imageType == ImageType.Primary)
+ {
+ if (itemsWithImages.Count == 0)
+ {
+ return null;
+ }
+
+ return await CreateThumbCollage(item, itemsWithImages, outputPath, 960, 540).ConfigureAwait(false);
+ }
+
+ return await base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex).ConfigureAwait(false);
+ }
+ }
+
+ public class ManualCollectionFolderImageProvider : BaseDynamicImageProvider<ManualCollectionsFolder>
+ {
+ private readonly ILibraryManager _libraryManager;
+
+ public ManualCollectionFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ _libraryManager = libraryManager;
+ }
+
+ public override IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ return new List<ImageType>
+ {
+ ImageType.Primary
+ };
+ }
+
+ protected override async Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var view = (ManualCollectionsFolder)item;
+
+ var recursive = !new[] { CollectionType.Playlists, CollectionType.Channels }.Contains(view.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Recursive = recursive,
+ IncludeItemTypes = new[] { typeof(BoxSet).Name },
+ Limit = 20,
+ SortBy = new[] { ItemSortBy.Random }
+ });
+
+ return GetFinalItems(items.Where(i => i.HasImage(ImageType.Primary) || i.HasImage(ImageType.Thumb)).ToList(), 8);
+ }
+
+ protected override bool Supports(IHasImages item)
+ {
+ return item is ManualCollectionsFolder;
+ }
+
+ protected override async Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ {
+ var outputPath = Path.ChangeExtension(outputPathWithoutExtension, ".png");
+
+ if (imageType == ImageType.Primary)
+ {
+ if (itemsWithImages.Count == 0)
+ {
+ return null;
+ }
+
+ return await CreateThumbCollage(item, itemsWithImages, outputPath, 960, 540).ConfigureAwait(false);
+ }
+
+ return await base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex).ConfigureAwait(false);
+ }
+ }
+
+}
diff --git a/Emby.Server.Implementations/UserViews/DynamicImageProvider.cs b/Emby.Server.Implementations/UserViews/DynamicImageProvider.cs
new file mode 100644
index 000000000..bef964c6f
--- /dev/null
+++ b/Emby.Server.Implementations/UserViews/DynamicImageProvider.cs
@@ -0,0 +1,172 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Images;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Extensions;
+
+namespace Emby.Server.Implementations.UserViews
+{
+ public class DynamicImageProvider : BaseDynamicImageProvider<UserView>
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+
+ public DynamicImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, IUserManager userManager, ILibraryManager libraryManager)
+ : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ }
+
+ public override IEnumerable<ImageType> GetSupportedImages(IHasImages item)
+ {
+ var view = (UserView)item;
+ if (IsUsingCollectionStrip(view))
+ {
+ return new List<ImageType>
+ {
+ ImageType.Primary
+ };
+ }
+
+ return new List<ImageType>
+ {
+ ImageType.Primary
+ };
+ }
+
+ protected override async Task<List<BaseItem>> GetItemsWithImages(IHasImages item)
+ {
+ var view = (UserView)item;
+
+ if (string.Equals(view.ViewType, CollectionType.LiveTv, StringComparison.OrdinalIgnoreCase))
+ {
+ var programs = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { typeof(LiveTvProgram).Name },
+ ImageTypes = new[] { ImageType.Primary },
+ Limit = 30,
+ IsMovie = true
+ }).ToList();
+
+ return GetFinalItems(programs).ToList();
+ }
+
+ if (string.Equals(view.ViewType, SpecialFolder.MovieGenre, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(view.ViewType, SpecialFolder.TvGenre, StringComparison.OrdinalIgnoreCase))
+ {
+ var userItemsResult = await view.GetItems(new InternalItemsQuery
+ {
+ CollapseBoxSetItems = false
+ });
+
+ return userItemsResult.Items.ToList();
+ }
+
+ var isUsingCollectionStrip = IsUsingCollectionStrip(view);
+ var recursive = isUsingCollectionStrip && !new[] { CollectionType.Channels, CollectionType.BoxSets, CollectionType.Playlists }.Contains(view.ViewType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+ var result = await view.GetItems(new InternalItemsQuery
+ {
+ User = view.UserId.HasValue ? _userManager.GetUserById(view.UserId.Value) : null,
+ CollapseBoxSetItems = false,
+ Recursive = recursive,
+ ExcludeItemTypes = new[] { "UserView", "CollectionFolder", "Person" },
+
+ }).ConfigureAwait(false);
+
+ var items = result.Items.Select(i =>
+ {
+ var episode = i as Episode;
+ if (episode != null)
+ {
+ var series = episode.Series;
+ if (series != null)
+ {
+ return series;
+ }
+
+ return episode;
+ }
+
+ var season = i as Season;
+ if (season != null)
+ {
+ var series = season.Series;
+ if (series != null)
+ {
+ return series;
+ }
+
+ return season;
+ }
+
+ var audio = i as Audio;
+ if (audio != null)
+ {
+ var album = audio.AlbumEntity;
+ if (album != null && album.HasImage(ImageType.Primary))
+ {
+ return album;
+ }
+ }
+
+ return i;
+
+ }).DistinctBy(i => i.Id);
+
+ if (isUsingCollectionStrip)
+ {
+ return GetFinalItems(items.Where(i => i.HasImage(ImageType.Primary) || i.HasImage(ImageType.Thumb)).ToList(), 8);
+ }
+
+ return GetFinalItems(items.Where(i => i.HasImage(ImageType.Primary)).ToList());
+ }
+
+ protected override bool Supports(IHasImages item)
+ {
+ var view = item as UserView;
+ if (view != null)
+ {
+ return IsUsingCollectionStrip(view);
+ }
+
+ return false;
+ }
+
+ private bool IsUsingCollectionStrip(UserView view)
+ {
+ string[] collectionStripViewTypes =
+ {
+ CollectionType.Movies,
+ CollectionType.TvShows
+ };
+
+ return collectionStripViewTypes.Contains(view.ViewType ?? string.Empty);
+ }
+
+ protected override async Task<string> CreateImage(IHasImages item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
+ {
+ if (itemsWithImages.Count == 0)
+ {
+ return null;
+ }
+
+ var outputPath = Path.ChangeExtension(outputPathWithoutExtension, ".png");
+
+ return await CreateThumbCollage(item, itemsWithImages, outputPath, 960, 540).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/packages.config b/Emby.Server.Implementations/packages.config
new file mode 100644
index 000000000..09b0af898
--- /dev/null
+++ b/Emby.Server.Implementations/packages.config
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<packages>
+ <package id="Emby.XmlTv" version="1.0.3" targetFramework="portable45-net45+win8" />
+ <package id="MediaBrowser.Naming" version="1.0.3" targetFramework="portable45-net45+win8" />
+ <package id="SQLitePCL.pretty" version="1.1.0" targetFramework="portable45-net45+win8" />
+ <package id="SQLitePCLRaw.core" version="1.1.1" targetFramework="portable45-net45+win8" />
+ <package id="UniversalDetector" version="1.0.1" targetFramework="portable45-net45+win8" />
+</packages> \ No newline at end of file