538 lines
24 KiB
C#
538 lines
24 KiB
C#
using dy.net.job;
|
||
using dy.net.model.dto;
|
||
using dy.net.model.entity;
|
||
using dy.net.utils;
|
||
using Quartz;
|
||
using Serilog;
|
||
|
||
namespace dy.net.service
|
||
{
|
||
/// <summary>
|
||
/// 抖音相关定时任务服务
|
||
/// </summary>
|
||
public class DouyinQuartzJobService
|
||
{
|
||
private readonly ISchedulerFactory _schedulerFactory;
|
||
private readonly DouyinCookieService douyinCookieService;
|
||
private readonly DouyinCommonService _commonService;
|
||
private readonly VideoTaskService _videoTasks;
|
||
private const string DefaultJobGroup = "dysync.net";
|
||
private const int DefaultIntervalMinutes = 30;
|
||
private const int DefaultCronStartDelaySeconds = 30;
|
||
private const int DefaultSimpleStartDelaySeconds = 3;
|
||
|
||
// 任务配置信息(修复了series任务的Key重复问题,确保每个任务Key唯一)
|
||
public static Dictionary<VideoTypeEnum, JobConfig> JobConfigs { get; } = new()
|
||
{
|
||
{
|
||
VideoTypeEnum.dy_collects,
|
||
new JobConfig(
|
||
typeof(DouyinCollectSyncJob),
|
||
"dy.job.key.collect",
|
||
"dy.trigger.key.collect",
|
||
"抖音收藏同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_favorite,
|
||
new JobConfig(
|
||
typeof(DouyinFavoritSyncJob),
|
||
"dy.job.key.favorite",
|
||
"dy.trigger.key.favorite",
|
||
"抖音点赞同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_follows,
|
||
new JobConfig(
|
||
typeof(DouyinFollowedSyncJob),
|
||
"dy.job.key.followed",
|
||
"dy.trigger.key.followed",
|
||
"抖音关注博主作品同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_followuser,
|
||
new JobConfig(
|
||
typeof(DouyinFollowsAndCollnectsSyncJob),
|
||
"dy.job.key.follow_user",
|
||
"dy.trigger.key.follow_user",
|
||
"抖音关注列表同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_custom_collect,
|
||
new JobConfig(
|
||
typeof(DouyinCollectCustomSyncJob),
|
||
"dy.job.key.custom_collect",
|
||
"dy.trigger.key.custom_collect",
|
||
"抖音自定义收藏夹列表同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_mix,
|
||
new JobConfig(
|
||
typeof(DouyinMixSyncJob),
|
||
"dy.job.key.mix",
|
||
"dy.trigger.key.mix",
|
||
"抖音收藏夹合集同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_series,
|
||
new JobConfig(
|
||
typeof(DouyinSeriesSyncJob),
|
||
"dy.job.key.series",
|
||
"dy.trigger.key.series",
|
||
"抖音收藏夹短剧同步任务")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_followuser_once,
|
||
new JobConfig(
|
||
typeof(DouyinFollowsAndCollnectsSyncJob),
|
||
"dy.job.key.sync_follow_user_once",
|
||
"dy.trigger.key.sync_follow_user_once",
|
||
"抖音关注同步任务(单次执行)")
|
||
},
|
||
{
|
||
VideoTypeEnum.dy_live_monitor,
|
||
new JobConfig(
|
||
typeof(DouyinLiveStatusJob),
|
||
"dy.job.key.live_monitor",
|
||
"dy.trigger.key.live_monitor",
|
||
"抖音博主直播状态监测任务")
|
||
}
|
||
};
|
||
|
||
public DouyinQuartzJobService(
|
||
ISchedulerFactory schedulerFactory,
|
||
DouyinCookieService douyinCookieService,
|
||
DouyinCommonService commonService,
|
||
VideoTaskService videoTasks)
|
||
{
|
||
_schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory));
|
||
this.douyinCookieService = douyinCookieService;
|
||
_commonService = commonService;
|
||
_videoTasks = videoTasks;
|
||
}
|
||
|
||
public async Task<List<VideoDownloadTask>> TriggerVideoJobsNowAsync(VideoTypeEnum? requestedType = null)
|
||
{
|
||
var cookies = await douyinCookieService.GetOpendCookiesAsync();
|
||
if (cookies == null || cookies.Count == 0) throw new InvalidOperationException("没有已启用的抖音授权账号。");
|
||
var storageType = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
|
||
var enabled = GetTaskEnableConditions(cookies, storageType);
|
||
var types = new List<VideoTypeEnum>();
|
||
if (enabled.IsFavoriteEnabled) types.Add(VideoTypeEnum.dy_favorite);
|
||
if (enabled.IsCollectEnabled) types.Add(VideoTypeEnum.dy_collects);
|
||
if (enabled.IsFollowedEnabled) types.Add(VideoTypeEnum.dy_follows);
|
||
if (enabled.IsCustomCollectEnabled) types.Add(VideoTypeEnum.dy_custom_collect);
|
||
if (enabled.IsMixEnabled) types.Add(VideoTypeEnum.dy_mix);
|
||
if (enabled.IsSeriesEnabled) types.Add(VideoTypeEnum.dy_series);
|
||
if (types.Count == 0) throw new InvalidOperationException("没有已启用且路径完整的视频同步类型。");
|
||
if (requestedType.HasValue)
|
||
{
|
||
if (!IsVideoSyncType(requestedType.Value))
|
||
throw new InvalidOperationException("指定的任务类型不是可手动执行的视频同步类型。");
|
||
if (!types.Contains(requestedType.Value))
|
||
{
|
||
var message = requestedType.Value == VideoTypeEnum.dy_follows
|
||
? "关注视频同步未启用:请检查账号的关注下载开关、当前存储的关注路径,以及是否有博主开启同步。"
|
||
: $"{requestedType.Value.GetDesc()}同步未启用或当前存储路径未配置。";
|
||
throw new InvalidOperationException(message);
|
||
}
|
||
types = new List<VideoTypeEnum> { requestedType.Value };
|
||
}
|
||
|
||
var scheduler = await _schedulerFactory.GetScheduler();
|
||
var created = new List<VideoDownloadTask>();
|
||
foreach (var type in types)
|
||
{
|
||
var config = JobConfigs[type];
|
||
var jobKey = new JobKey(config.JobKey, DefaultJobGroup);
|
||
if (!await scheduler.CheckExists(jobKey))
|
||
await StartJobAsync(type, (_commonService.GetConfig()?.Cron ?? DefaultIntervalMinutes).ToString());
|
||
var task = await _videoTasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual,
|
||
$"{type.GetDesc()}手动同步", type, storageType);
|
||
try
|
||
{
|
||
await scheduler.TriggerJob(jobKey, new JobDataMap
|
||
{
|
||
["video-task-id"] = task.Id,
|
||
["video-task-trigger"] = "manual"
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var message = $"{type.GetDesc()}手动同步未能进入调度,请稍后重试。";
|
||
await _videoTasks.CompleteTaskAsync(task.Id, message);
|
||
Log.Error(ex, "手动同步任务调度失败:{VideoType}, TaskId={TaskId}", type, task.Id);
|
||
throw new InvalidOperationException(message, ex);
|
||
}
|
||
created.Add(task);
|
||
}
|
||
return created;
|
||
}
|
||
|
||
internal static bool IsVideoSyncType(VideoTypeEnum type) => type is
|
||
VideoTypeEnum.dy_favorite or VideoTypeEnum.dy_collects or VideoTypeEnum.dy_follows or
|
||
VideoTypeEnum.dy_custom_collect or VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series;
|
||
|
||
|
||
/// <summary>
|
||
/// 初始化或重启所有抖音定时任务
|
||
/// </summary>
|
||
/// <param name="cronExpression">定时任务表达式(分钟数)</param>
|
||
/// <returns>是否成功初始化</returns>
|
||
public async Task<bool> InitOrReStartAllJobs(string cronExpression)
|
||
{
|
||
try
|
||
{
|
||
// 1. 获取并验证Cookie
|
||
var validCookies = await douyinCookieService.GetOpendCookiesAsync();
|
||
if (validCookies == null || !validCookies.Any())
|
||
{
|
||
Serilog.Log.Debug("没有有效的抖音Cookie,无法启动定时任务");
|
||
return false;
|
||
}
|
||
|
||
// 2. 处理定时任务表达式
|
||
var taskIntervalExpression = ResolveTaskExpression(cronExpression);
|
||
|
||
// 3. 获取调度器并清理现有任务
|
||
var scheduler = await _schedulerFactory.GetScheduler();
|
||
if (scheduler == null)
|
||
{
|
||
Log.Error("获取任务调度器失败,无法初始化定时任务");
|
||
return false;
|
||
}
|
||
|
||
await RemoveAllExistingJobs(scheduler);
|
||
|
||
// 4. 检查各类型任务的启用条件
|
||
var storageType = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
|
||
var taskEnableConditions = GetTaskEnableConditions(validCookies, storageType);
|
||
|
||
// 5. 启动符合条件的定时任务
|
||
int successfullyStartedJobs = 0;
|
||
foreach (var jobKey in JobConfigs.Keys)
|
||
{
|
||
// 跳过一次性关注用户任务
|
||
if (jobKey == VideoTypeEnum.dy_followuser_once)
|
||
continue;
|
||
|
||
// 处理关注用户任务(固定60分钟执行频率)
|
||
if (jobKey == VideoTypeEnum.dy_followuser)
|
||
{
|
||
bool startSuccess = await StartSingleJobAsync(jobKey, "60");
|
||
if (startSuccess) successfullyStartedJobs++;
|
||
continue;
|
||
}
|
||
|
||
// 直播监测完全独立于视频同步,固定每5分钟检查已单独开启的博主。
|
||
if (jobKey == VideoTypeEnum.dy_live_monitor)
|
||
{
|
||
bool startSuccess = await StartSingleJobAsync(jobKey, "5");
|
||
if (startSuccess) successfullyStartedJobs++;
|
||
continue;
|
||
}
|
||
|
||
// 根据不同任务类型和启用条件启动任务
|
||
bool isTaskEnabled = jobKey switch
|
||
{
|
||
VideoTypeEnum.dy_favorite => taskEnableConditions.IsFavoriteEnabled,
|
||
VideoTypeEnum.dy_collects => taskEnableConditions.IsCollectEnabled,
|
||
VideoTypeEnum.dy_follows => taskEnableConditions.IsFollowedEnabled,
|
||
VideoTypeEnum.dy_custom_collect => taskEnableConditions.IsCustomCollectEnabled,
|
||
VideoTypeEnum.dy_mix => taskEnableConditions.IsMixEnabled,
|
||
VideoTypeEnum.dy_series => taskEnableConditions.IsSeriesEnabled,
|
||
_ => false
|
||
};
|
||
|
||
if (isTaskEnabled)
|
||
{
|
||
bool startSuccess = await StartSingleJobAsync(jobKey, taskIntervalExpression);
|
||
if (startSuccess) successfullyStartedJobs++;
|
||
}
|
||
}
|
||
|
||
// 6. 输出任务启动统计日志
|
||
Log.Information($"定时任务初始化完成,共尝试启动 {JobConfigs.Count - 1} 个任务,成功启动 {successfullyStartedJobs} 个");
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "【quartz】初始化所有抖音定时任务时发生异常");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 解析任务执行表达式,为空时使用默认值
|
||
/// </summary>
|
||
/// <param name="inputExpression">输入的表达式</param>
|
||
/// <returns>处理后的表达式</returns>
|
||
private static string ResolveTaskExpression(string inputExpression)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(inputExpression))
|
||
{
|
||
Log.Debug("定时任务表达式为空,使用默认配置({DefaultMinutes}分钟)", DefaultIntervalMinutes);
|
||
return DefaultIntervalMinutes.ToString();
|
||
}
|
||
return inputExpression;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取各类型任务的启用条件
|
||
/// </summary>
|
||
/// <param name="cookies">有效的抖音Cookie列表</param>
|
||
/// <returns>任务启用条件集合</returns>
|
||
private static TaskEnableConditions GetTaskEnableConditions(IEnumerable<DouyinCookie> cookies, StorageType storageType)
|
||
{
|
||
bool HasPath(DouyinCookie cookie, VideoTypeEnum type)
|
||
{
|
||
if (storageType.IsRemote())
|
||
{
|
||
return type switch
|
||
{
|
||
VideoTypeEnum.dy_favorite => !string.IsNullOrWhiteSpace(cookie.WebDavFavoritePath),
|
||
VideoTypeEnum.dy_follows => !string.IsNullOrWhiteSpace(cookie.WebDavFollowPath),
|
||
VideoTypeEnum.dy_mix => !string.IsNullOrWhiteSpace(cookie.WebDavMixPath),
|
||
VideoTypeEnum.dy_series => !string.IsNullOrWhiteSpace(cookie.WebDavSeriesPath),
|
||
_ => !string.IsNullOrWhiteSpace(cookie.WebDavCollectPath)
|
||
};
|
||
}
|
||
|
||
return type switch
|
||
{
|
||
VideoTypeEnum.dy_favorite => !string.IsNullOrWhiteSpace(cookie.FavSavePath),
|
||
VideoTypeEnum.dy_follows => !string.IsNullOrWhiteSpace(cookie.UpSavePath),
|
||
VideoTypeEnum.dy_mix => !string.IsNullOrWhiteSpace(cookie.MixPath) || !string.IsNullOrWhiteSpace(cookie.SavePath),
|
||
VideoTypeEnum.dy_series => !string.IsNullOrWhiteSpace(cookie.SeriesPath) || !string.IsNullOrWhiteSpace(cookie.SavePath),
|
||
_ => !string.IsNullOrWhiteSpace(cookie.SavePath)
|
||
};
|
||
}
|
||
|
||
return new TaskEnableConditions
|
||
{
|
||
IsCollectEnabled = cookies.Any(x => x.DownCollect && !x.UseCollectFolder && HasPath(x, VideoTypeEnum.dy_collects)),
|
||
IsFavoriteEnabled = cookies.Any(x => x.DownFavorite && HasPath(x, VideoTypeEnum.dy_favorite)),
|
||
IsFollowedEnabled = cookies.Any(x => x.DownFollowd && HasPath(x, VideoTypeEnum.dy_follows)),
|
||
IsMixEnabled = cookies.Any(x => x.DownMix && HasPath(x, VideoTypeEnum.dy_mix)),
|
||
IsSeriesEnabled = cookies.Any(x => x.DownSeries && HasPath(x, VideoTypeEnum.dy_series)),
|
||
IsCustomCollectEnabled = cookies.Any(x => x.UseCollectFolder && HasPath(x, VideoTypeEnum.dy_custom_collect))
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启动单个定时任务(封装重复的启动逻辑)
|
||
/// </summary>
|
||
/// <param name="jobKey">任务类型</param>
|
||
/// <param name="expression">执行频率表达式</param>
|
||
/// <returns>是否启动成功</returns>
|
||
private async Task<bool> StartSingleJobAsync(VideoTypeEnum jobKey, string expression)
|
||
{
|
||
try
|
||
{
|
||
bool startSuccess = await StartJobAsync(jobKey, expression);
|
||
if (startSuccess)
|
||
{
|
||
Log.Debug($"【quartz】成功启动任务:{jobKey},执行频率:{expression}分钟");
|
||
}
|
||
else
|
||
{
|
||
Log.Error($"【quartz】启动任务失败:{jobKey}");
|
||
}
|
||
return startSuccess;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, $"【quartz】启动任务 {jobKey} 时发生异常");
|
||
return false;
|
||
}
|
||
}
|
||
/// <summary>
|
||
/// 启动关注同步任务(单次执行)
|
||
/// </summary>
|
||
public async Task<bool> StartFollowJobOnceAsync()
|
||
{
|
||
return await StartOneTimeJobAsync(VideoTypeEnum.dy_followuser_once);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 移除所有已存在的任务(避免重复调度)
|
||
/// </summary>
|
||
private static async Task RemoveAllExistingJobs(IScheduler scheduler)
|
||
{
|
||
var jobKeys = JobConfigs.Values.Select(config => new JobKey(config.JobKey, DefaultJobGroup)).ToList();
|
||
foreach (var jobKey in jobKeys)
|
||
{
|
||
if (await scheduler.CheckExists(jobKey))
|
||
{
|
||
//Log.Debug("【quartz】移除已存在的任务: {JobKey}", jobKey);
|
||
await scheduler.DeleteJob(jobKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启动指定定时任务(独立执行,无依赖触发)
|
||
/// </summary>
|
||
/// <param name="configKey">任务配置Key(如:collect、favorite)</param>
|
||
/// <param name="expression">定时表达式(Cron或间隔分钟数)</param>
|
||
/// <returns>是否启动成功</returns>
|
||
public async Task<bool> StartJobAsync(VideoTypeEnum configKey, string expression)
|
||
{
|
||
if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
|
||
{
|
||
Log.Error("【quartz】找不到任务配置: {ConfigKey}", configKey);
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
var scheduler = await _schedulerFactory.GetScheduler();
|
||
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
|
||
var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
|
||
|
||
// 移除已存在的任务(防止重复执行)
|
||
await RemoveExistingJobAsync(scheduler, jobKey);
|
||
|
||
// 创建任务详情(保留禁止并发执行,避免同一任务重复运行)
|
||
var jobDetail = JobBuilder.Create(jobConfig.JobType)
|
||
.WithIdentity(jobKey)
|
||
.WithDescription(jobConfig.Description)
|
||
.DisallowConcurrentExecution() // 禁止同一任务并发执行
|
||
.Build();
|
||
|
||
// 创建定时触发器(仅使用定时触发,移除依赖触发逻辑)
|
||
ITrigger trigger = CreateScheduledTrigger(triggerKey, expression, jobConfig.Description);
|
||
|
||
// 调度任务
|
||
await scheduler.ScheduleJob(jobDetail, trigger);
|
||
Log.Information("【quartz】启动任务成功 - 任务描述: {JobDescription}, 执行频率: {Expression}",
|
||
jobConfig.Description,
|
||
expression);
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "【quartz】启动任务失败 - 任务描述: {JobDescription}", jobConfig.Description);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启动单次执行任务
|
||
/// </summary>
|
||
private async Task<bool> StartOneTimeJobAsync(VideoTypeEnum configKey)
|
||
{
|
||
if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
|
||
{
|
||
Log.Error("【quartz】找不到任务配置: {ConfigKey}", configKey);
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
var scheduler = await _schedulerFactory.GetScheduler();
|
||
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
|
||
var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
|
||
|
||
await RemoveExistingJobAsync(scheduler, jobKey);
|
||
|
||
var jobDetail = JobBuilder.Create(jobConfig.JobType)
|
||
.WithIdentity(jobKey)
|
||
.WithDescription(jobConfig.Description)
|
||
.DisallowConcurrentExecution()
|
||
.Build();
|
||
|
||
var trigger = TriggerBuilder.Create()
|
||
.WithIdentity(triggerKey)
|
||
.WithDescription($"{jobConfig.Description} - 单次执行")
|
||
.StartNow()
|
||
.Build();
|
||
|
||
await scheduler.ScheduleJob(jobDetail, trigger);
|
||
Log.Debug("【quartz】启动单次任务成功 - 任务描述: {JobDescription}", jobConfig.Description);
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(ex, "【quartz】启动单次任务失败 - 任务描述: {JobDescription}", jobConfig.Description);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建定时触发器(支持Cron表达式或分钟间隔)
|
||
/// </summary>
|
||
private static ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription)
|
||
{
|
||
// Cron表达式格式
|
||
if (CronExpression.IsValidExpression(expression))
|
||
{
|
||
return TriggerBuilder.Create()
|
||
.WithIdentity(triggerKey)
|
||
.WithDescription($"{jobDescription} - Cron调度")
|
||
.WithCronSchedule(expression)
|
||
.StartAt(DateTime.Now.AddSeconds(DefaultCronStartDelaySeconds))
|
||
.Build();
|
||
}
|
||
|
||
// 数字间隔格式(分钟)
|
||
if (int.TryParse(expression, out int intervalMinutes))
|
||
{
|
||
intervalMinutes = Math.Max(1, intervalMinutes); // 最小间隔1分钟
|
||
return TriggerBuilder.Create()
|
||
.WithIdentity(triggerKey)
|
||
.WithDescription($"{jobDescription} - 间隔{intervalMinutes}分钟调度")
|
||
.StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
|
||
.WithSimpleSchedule(x => x
|
||
.WithIntervalInMinutes(intervalMinutes)
|
||
.RepeatForever())
|
||
.Build();
|
||
}
|
||
|
||
// 无效表达式,使用默认配置
|
||
//Log.Debug("【任务服务】无效的任务表达式: {Expression},使用默认间隔{DefaultMinutes}分钟",
|
||
// expression, DefaultIntervalMinutes);
|
||
|
||
return TriggerBuilder.Create()
|
||
.WithIdentity(triggerKey)
|
||
.WithDescription($"{jobDescription} - 默认间隔调度")
|
||
.StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
|
||
.WithSimpleSchedule(x => x
|
||
.WithIntervalInMinutes(DefaultIntervalMinutes)
|
||
.RepeatForever())
|
||
.Build();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 移除已存在的任务
|
||
/// </summary>
|
||
private static async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
|
||
{
|
||
if (await scheduler.CheckExists(jobKey))
|
||
{
|
||
//Log.Debug("【quartz】移除已存在的任务: {JobKey}", jobKey);
|
||
await scheduler.DeleteJob(jobKey);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// 任务启用条件模型
|
||
/// </summary>
|
||
private class TaskEnableConditions
|
||
{
|
||
public bool IsCollectEnabled { get; set; }
|
||
public bool IsFavoriteEnabled { get; set; }
|
||
public bool IsFollowedEnabled { get; set; }
|
||
public bool IsMixEnabled { get; set; }
|
||
public bool IsSeriesEnabled { get; set; }
|
||
public bool IsCustomCollectEnabled { get; set; }
|
||
}
|
||
}
|
||
}
|